feat: refine builder skill chip flows
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
items,
|
||||
value = $bindable(''),
|
||||
label,
|
||||
placeholder = 'Search available items…',
|
||||
placeholder = 'Search…',
|
||||
disabledIds = [],
|
||||
onchange
|
||||
}: {
|
||||
@@ -33,13 +33,13 @@
|
||||
<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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b>STANDARD</b><b>SPECIALIZED</b><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Shared profiles define primary and secondary ranges.</small></div></div>
|
||||
<section class="skill-chip-ability-assignment">
|
||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||||
<div class="selection-head"><div><span>OWNING ABILITY</span><small>THREE ACTIVE DEFINITIONS · ABILITY SHARING IS CONFIGURED IN SPECIALIZATIONS</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 OWNED` : 'REQUIRED'}</b></div>
|
||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||
</section>
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<div class="dashboard-table">
|
||||
<div class="dashboard-row skill-chip-row table-heading"><SortableColumnHeader label="SKILL CHIP" column="name" sort={data.sort}/><SortableColumnHeader label="ABILITY" column="ability" sort={data.sort}/><span>QUALITY</span><SortableColumnHeader label="2-PIECE BONUS" column="twoPiece" sort={data.sort}/><SortableColumnHeader label="3-PIECE TALENT" column="talent" sort={data.sort}/><span>ROLL PROFILE</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.sets as item}
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>EXCLUSIVE ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row skill-chip-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-icon-identity"><ItemIcon src={item.iconUrl} alt=""/><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><div class="skill-chip-ability-cell"><strong>{ability(String(item.specialization_ability_id))?.name ?? 'UNKNOWN ABILITY'}</strong><small>OWNING ABILITY</small></div><span class="skill-chip-quality-pair"><b>STD</b><b>SP</b><b class="quality-superior">SUP</b><b class="quality-highend">HE</b></span><span>{item.twoPiece ? `${stat(item.twoPiece.stat_attribute_id)?.name ?? 'UNKNOWN'} · ${format(item.twoPiece.min_value, item.twoPiece.stat_attribute_id)}–${format(item.twoPiece.max_value, item.twoPiece.stat_attribute_id)}` : 'NOT CONFIGURED'}</span><div><strong>{item.talent?.name ?? 'NOT ASSIGNED'}</strong><small>{item.talent ? item.talent.conditionalRule?.clauses?.length || item.pending ? 'MODELED · QUALITY SCALED' : 'CONFIG REQUIRED' : 'OPTIONAL'}</small></div><span>SHARED · 4 PRIMARY · {data.secondaryPool.length} SECONDARY</span><b class:pending={item.pending} class="state-dot">{item.pending ? 'PENDING' : Number(item.active) ? 'ACTIVE' : 'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button aria-expanded={editing?.id === item.id} aria-controls={`skill-chip-editor-${item.id}`} onclick={() => edit(item)}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}<RegistryInlineEditor id={`skill-chip-editor-${item.id}`} label={`Edit ${item.name}`}>{@render skillChipEditor()}</RegistryInlineEditor>{/if}
|
||||
{:else}<div class="empty-dashboard"><strong>NO SKILL CHIP SETS REGISTERED</strong><span>Add the first ability-linked Skill Chip definition.</span></div>{/each}
|
||||
</div>
|
||||
|
||||
@@ -187,6 +187,14 @@ async function focusSnapshot(database: Database, id: string) {
|
||||
async function abilitySnapshot(database: Database, id: string) {
|
||||
return {
|
||||
record: await auditRecord('specialization_abilities', id),
|
||||
skillChipLinks: await (
|
||||
await database.prepare(
|
||||
`SELECT ability_id,compatible_ability_id
|
||||
FROM specialization_ability_skill_chip_links
|
||||
WHERE ability_id=? OR compatible_ability_id=?
|
||||
ORDER BY ability_id,compatible_ability_id`,
|
||||
)
|
||||
).all(id, id),
|
||||
attack:
|
||||
(await (
|
||||
await database.prepare(
|
||||
@@ -398,6 +406,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'cooldownSeconds',
|
||||
'effectsJson',
|
||||
'attackJson',
|
||||
'skillChipCompatibleAbilityId',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -470,6 +479,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
specializations,
|
||||
focuses,
|
||||
abilities,
|
||||
abilitySkillChipLinks,
|
||||
abilityAttacks,
|
||||
abilityEffects,
|
||||
focusAbilities,
|
||||
@@ -506,6 +516,11 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'SELECT * FROM specialization_abilities ORDER BY specialization_id,active DESC,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_ability_skill_chip_links ORDER BY ability_id,compatible_ability_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
||||
).all(),
|
||||
@@ -672,6 +687,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
})),
|
||||
...focuses,
|
||||
];
|
||||
const linkedAbilityById = new Map<string, string>();
|
||||
for (const link of abilitySkillChipLinks) {
|
||||
linkedAbilityById.set(
|
||||
String(link.ability_id),
|
||||
String(link.compatible_ability_id),
|
||||
);
|
||||
linkedAbilityById.set(
|
||||
String(link.compatible_ability_id),
|
||||
String(link.ability_id),
|
||||
);
|
||||
}
|
||||
const loadedAbilities = [
|
||||
...pendingAbilities.map((item) => ({
|
||||
...item,
|
||||
@@ -680,6 +706,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
cooldown_seconds: item.cooldownSeconds || null,
|
||||
attack: item.attackJson ? JSON.parse(String(item.attackJson)) : null,
|
||||
effects: parseJsonArray<AbilityEffectInput>(item.effectsJson),
|
||||
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId || '',
|
||||
active: 1,
|
||||
pending: true,
|
||||
skillChipCount: 0,
|
||||
@@ -698,6 +725,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
chipCounts.find((item) => item.specialization_ability_id === ability.id)
|
||||
?.count ?? 0,
|
||||
),
|
||||
skillChipCompatibleAbilityId:
|
||||
linkedAbilityById.get(String(ability.id)) ?? '',
|
||||
iconUrl: iconUrl('specialization_ability', String(ability.id)),
|
||||
renderUrl: renderUrl('specialization_ability', String(ability.id)),
|
||||
})),
|
||||
@@ -1160,7 +1189,12 @@ export const actions: Actions = {
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const abilityId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const specializationId = String(form.get('specializationId') ?? '');
|
||||
const skillChipCompatibleAbilityId = String(
|
||||
form.get('skillChipCompatibleAbilityId') ?? '',
|
||||
);
|
||||
const name = cleanName(form.get('name'));
|
||||
const description = cleanName(form.get('description'));
|
||||
const durationSeconds = optionalPositive(form.get('durationSeconds'));
|
||||
@@ -1312,6 +1346,46 @@ export const actions: Actions = {
|
||||
) {
|
||||
return fail(400, { abilityError: 'Choose an active specialization.' });
|
||||
}
|
||||
if (skillChipCompatibleAbilityId) {
|
||||
if (skillChipCompatibleAbilityId === abilityId) {
|
||||
return fail(400, {
|
||||
abilityError: 'An ability cannot share Skill Chips with itself.',
|
||||
});
|
||||
}
|
||||
const compatibleAbility = await (
|
||||
await database.prepare(
|
||||
'SELECT id,specialization_id FROM specialization_abilities WHERE id=? AND active=1',
|
||||
)
|
||||
).get(skillChipCompatibleAbilityId);
|
||||
if (
|
||||
!compatibleAbility ||
|
||||
String(compatibleAbility.specialization_id) !== specializationId
|
||||
) {
|
||||
return fail(400, {
|
||||
abilityError:
|
||||
'Choose an active Skill Chip-compatible ability from the same specialization.',
|
||||
});
|
||||
}
|
||||
const conflictingLink = await (
|
||||
await database.prepare(
|
||||
`SELECT ability_id,compatible_ability_id
|
||||
FROM specialization_ability_skill_chip_links
|
||||
WHERE (ability_id=? OR compatible_ability_id=?)
|
||||
AND ability_id<>? AND compatible_ability_id<>?`,
|
||||
)
|
||||
).get(
|
||||
skillChipCompatibleAbilityId,
|
||||
skillChipCompatibleAbilityId,
|
||||
abilityId,
|
||||
abilityId,
|
||||
);
|
||||
if (conflictingLink) {
|
||||
return fail(409, {
|
||||
abilityError:
|
||||
'Each ability can share one Skill Chip pool. Clear the existing link first.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
!(await validateAttributeIds(
|
||||
database,
|
||||
@@ -1387,8 +1461,6 @@ export const actions: Actions = {
|
||||
: 'The ability icon could not be processed.',
|
||||
});
|
||||
}
|
||||
const abilityId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await abilitySnapshot(database, id) : null;
|
||||
const iconChanged = Boolean(iconMutation.output || iconMutation.remove);
|
||||
const previousIcon = iconChanged
|
||||
@@ -1426,6 +1498,24 @@ export const actions: Actions = {
|
||||
now,
|
||||
);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
`DELETE FROM specialization_ability_skill_chip_links
|
||||
WHERE ability_id=? OR compatible_ability_id=?`,
|
||||
)
|
||||
).run(abilityId, abilityId);
|
||||
if (skillChipCompatibleAbilityId) {
|
||||
const [leftAbilityId, rightAbilityId] = [
|
||||
abilityId,
|
||||
skillChipCompatibleAbilityId,
|
||||
].sort();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO specialization_ability_skill_chip_links
|
||||
(ability_id,compatible_ability_id) VALUES(?,?)`,
|
||||
)
|
||||
).run(leftAbilityId, rightAbilityId);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM specialization_ability_effects WHERE ability_id=?',
|
||||
@@ -2326,6 +2416,12 @@ export const actions: Actions = {
|
||||
'UPDATE specialization_abilities SET active=0,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id);
|
||||
await (
|
||||
await database.prepare(
|
||||
`DELETE FROM specialization_ability_skill_chip_links
|
||||
WHERE ability_id=? OR compatible_ability_id=?`,
|
||||
)
|
||||
).run(id, id);
|
||||
if (ability)
|
||||
await refreshSpecializationFocuses(
|
||||
database,
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
description: string;
|
||||
durationSeconds: number | string;
|
||||
cooldownSeconds: number | string;
|
||||
skillChipCompatibleAbilityId: string;
|
||||
attack: {
|
||||
enabled: boolean;
|
||||
baseDamage: number | string;
|
||||
@@ -97,7 +98,7 @@
|
||||
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
||||
});
|
||||
const blankAbility = (specializationId = ''): AbilityEditor => ({
|
||||
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '',
|
||||
id: '', specializationId, name: '', description: '', durationSeconds: '', cooldownSeconds: '', skillChipCompatibleAbilityId: '',
|
||||
attack: {
|
||||
enabled: false, baseDamage: '', damageType: 'Blast', delivery: 'direct',
|
||||
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
|
||||
@@ -161,10 +162,25 @@
|
||||
const abilityById = (id: string) => data.abilities.find((item: any) => item.id === id);
|
||||
const combatEffectById = (id: string) => data.combatEffects.find((item: any) => item.id === id);
|
||||
const abilityChipCount = (ability: any) => Number(ability?.skillChipCount ?? 0);
|
||||
const abilityEligibleChipCount = (ability: any) => abilityChipCount(ability)
|
||||
+ abilityChipCount(abilityById(String(ability?.skillChipCompatibleAbilityId ?? '')));
|
||||
const focusList = (specializationId: string) => data.focuses.filter((focus: any) => focus.specialization_id === specializationId);
|
||||
const abilityList = (specializationId: string) => data.abilities.filter((ability: any) =>
|
||||
ability.specialization_id === specializationId && Number(ability.active) === 1
|
||||
);
|
||||
const skillChipCompatibilityOptions = (editor: AbilityEditor) => abilityList(editor.specializationId)
|
||||
.filter((ability: any) => ability.id !== editor.id)
|
||||
.map((ability: any) => ({
|
||||
...ability,
|
||||
detail: `${abilityChipCount(ability)} OWN CHIP SETS`,
|
||||
disabled: Boolean(
|
||||
ability.skillChipCompatibleAbilityId
|
||||
&& ability.skillChipCompatibleAbilityId !== editor.id
|
||||
),
|
||||
disabledReason: ability.skillChipCompatibleAbilityId && ability.skillChipCompatibleAbilityId !== editor.id
|
||||
? 'ALREADY SHARES ANOTHER POOL'
|
||||
: ''
|
||||
}));
|
||||
const combatEffectList = (specializationId: string) => data.combatEffects.filter((effect: any) =>
|
||||
effect.specialization_id === specializationId && Number(effect.active) === 1
|
||||
);
|
||||
@@ -234,6 +250,7 @@
|
||||
description: item.description,
|
||||
durationSeconds: item.duration_seconds ?? '',
|
||||
cooldownSeconds: item.cooldown_seconds ?? '',
|
||||
skillChipCompatibleAbilityId: item.skillChipCompatibleAbilityId ?? '',
|
||||
attack: item.attack ? {
|
||||
enabled: true,
|
||||
baseDamage: item.attack.base_damage,
|
||||
@@ -499,6 +516,20 @@
|
||||
<label class="field">COOLDOWN · SECONDS<input name="cooldownSeconds" type="number" min="0.01" step="any" bind:value={abilityEditing.cooldownSeconds} placeholder="OPTIONAL"/></label>
|
||||
<div><span>AVAILABILITY</span><strong>IMMEDIATE · NO POINT OR MATERIAL COST</strong><small>Assigned ability slots 1 and 3 are mirrored into the top-left of the focus tree.</small></div>
|
||||
</div>
|
||||
<div class="specialization-skill-chip-sharing">
|
||||
<div class="selection-head"><div><span>SHARED SKILL CHIP POOL</span><small>OPTIONAL · SAME SPECIALIZATION · 3 EQUIPPED SLOTS, UP TO 6 ELIGIBLE SETS</small></div><b>{abilityEditing.skillChipCompatibleAbilityId ? 'SHARED' : 'ISOLATED'}</b></div>
|
||||
<SearchableAssignment
|
||||
items={skillChipCompatibilityOptions(abilityEditing)}
|
||||
mode="single"
|
||||
compact
|
||||
fieldName="skillChipCompatibleAbilityId"
|
||||
subjectLabel="COMPATIBLE ABILITY"
|
||||
searchPlaceholder="Search this specialization…"
|
||||
emptySelectionLabel="NO SHARED SKILL CHIP POOL"
|
||||
bind:value={abilityEditing.skillChipCompatibleAbilityId}
|
||||
/>
|
||||
<p class="configuration-note">Link only abilities that can equip each other's Skill Chips in game. Unlinked abilities remain isolated.</p>
|
||||
</div>
|
||||
<div class="specialization-attack-profile">
|
||||
<div class="selection-head"><div><span>INTRINSIC ATTACK PROFILE</span><small>UNMODIFIED DAMAGE BEFORE ENGINEERING, ATTRIBUTES, TALENTS, AND TREE UNLOCKS</small></div><b>{abilityEditing.attack.enabled ? 'DAMAGING ABILITY' : 'NO DIRECT DAMAGE'}</b></div>
|
||||
<SegmentedChoice legend="DAMAGE MODEL" name="ability-damage-model" options={[{value:'disabled',label:'NO DAMAGE'},{value:'enabled',label:'MODELS DAMAGE'}]} value={abilityEditing.attack.enabled ? 'enabled' : 'disabled'} onchange={(values) => setAbilityAttackEnabled(values[0] === 'enabled')}/>
|
||||
@@ -728,7 +759,7 @@
|
||||
<span>ABILITY {slot} · {slot === 2 ? 'COMBAT SKILLS' : 'TREE + COMBAT SKILLS'}</span>
|
||||
<strong>{ability?.name ?? 'NOT ASSIGNED'}</strong>
|
||||
{#if ability}
|
||||
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} / 3 SKILL CHIPS · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
|
||||
<small class:ready={abilityChipCount(ability) === 3} class="ability-chip-readiness">{abilityChipCount(ability)} OWNED · {abilityEligibleChipCount(ability)} ELIGIBLE · {abilityChipCount(ability) === 3 ? 'READY' : 'INCOMPLETE'}</small>
|
||||
{/if}
|
||||
<p>{ability?.description ?? 'Assign an active class ability to complete this focus.'}</p>
|
||||
</div>
|
||||
@@ -746,7 +777,7 @@
|
||||
{#each data.abilities.filter((ability: any) => ability.specialization_id === selectedSpecialization.id) as ability}
|
||||
<article class:archived={Number(ability.active) === 0}>
|
||||
<ItemIcon src={ability.iconUrl} alt={ability.name}/>
|
||||
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} / 3 SKILL CHIPS` : 'ARCHIVED CLASS ABILITY'}</small></div>
|
||||
<div><strong>{ability.name}</strong><small>{Number(ability.active) === 1 ? `${abilityChipCount(ability)} OWNED · ${abilityEligibleChipCount(ability)} ELIGIBLE` : 'ARCHIVED CLASS ABILITY'}</small></div>
|
||||
<button class="secondary specialization-mutation" onclick={() => editAbility(ability)}>{Number(ability.active) === 1 ? 'EDIT' : 'REVIEW + RESTORE'} →</button>
|
||||
</article>
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const links: Array<{ ability_id: string; compatible_ability_id: string }> =
|
||||
[];
|
||||
return {
|
||||
links,
|
||||
auditRecord: vi.fn(async () => ({
|
||||
id: 'ability-1',
|
||||
specialization_id: 'specialization-1',
|
||||
name: 'Tactical Link',
|
||||
})),
|
||||
recordIsacAudit: vi.fn(),
|
||||
database: {
|
||||
exec: vi.fn(),
|
||||
prepare: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requireDataView: vi.fn(async (user) => user),
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => mocks.database),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
auditRecord: mocks.auditRecord,
|
||||
recordIsacAudit: mocks.recordIsacAudit,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/data-moderation', () => ({
|
||||
deserializeProposal: vi.fn(),
|
||||
getPendingEntities: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/game-data-icons', () => ({
|
||||
applyIconMutation: vi.fn(),
|
||||
gameDataIconStorageDirectory: vi.fn(),
|
||||
gameDataIconMetadata: vi.fn(async () => null),
|
||||
gameDataIconUrl: vi.fn(() => ''),
|
||||
GameDataIconValidationError: class extends Error {},
|
||||
readGameDataIcon: vi.fn(async () => null),
|
||||
readIconMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/managed-images', () => ({
|
||||
applyGameDataRender: vi.fn(),
|
||||
ManagedImageValidationError: class extends Error {},
|
||||
managedImageUrl: vi.fn(() => ''),
|
||||
processManagedImage: vi.fn(),
|
||||
readGameDataRenderMutation: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/specialization-readiness', () => ({
|
||||
refreshSpecializationFocusReady: vi.fn(),
|
||||
refreshSpecializationFocuses: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/conditional-effects', () => ({
|
||||
conditionalRuleSnapshot: vi.fn(),
|
||||
loadConditionalRuleDraft: vi.fn(),
|
||||
parseConditionalRulePayload: vi.fn(),
|
||||
saveConditionalRuleInTransaction: vi.fn(),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
const saveRequest = (compatibleAbilityId: string) => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'ability-1');
|
||||
form.set('specializationId', 'specialization-1');
|
||||
form.set('skillChipCompatibleAbilityId', compatibleAbilityId);
|
||||
form.set('name', 'Tactical Link');
|
||||
form.set('description', 'Deploy a tactical support link.');
|
||||
form.set('effectsJson', '[]');
|
||||
form.set('attackJson', '{"enabled":false}');
|
||||
return new Request('http://localhost/isac/specializations?/saveAbility', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
};
|
||||
|
||||
const runSave = (compatibleAbilityId: string) =>
|
||||
actions.saveAbility?.({
|
||||
cookies: {} as never,
|
||||
request: saveRequest(compatibleAbilityId),
|
||||
} as never);
|
||||
|
||||
const runArchive = () => {
|
||||
const form = new FormData();
|
||||
form.set('id', 'ability-1');
|
||||
return actions.archiveAbility?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/specializations?/archiveAbility',
|
||||
{ method: 'POST', body: form },
|
||||
),
|
||||
} as never);
|
||||
};
|
||||
|
||||
describe('Specialization ability Skill Chip sharing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.links.length = 0;
|
||||
mocks.database.prepare.mockImplementation(async (sql: string) => ({
|
||||
get: vi.fn(async (...args: unknown[]) => {
|
||||
if (
|
||||
sql.includes('SELECT specialization_id FROM specialization_abilities')
|
||||
)
|
||||
return { specialization_id: 'specialization-1' };
|
||||
if (sql.includes('SELECT id FROM specializations'))
|
||||
return { id: 'specialization-1' };
|
||||
if (
|
||||
sql.includes(
|
||||
'SELECT id,specialization_id FROM specialization_abilities',
|
||||
)
|
||||
)
|
||||
return {
|
||||
id: String(args[0]),
|
||||
specialization_id:
|
||||
args[0] === 'cross-specialization-ability'
|
||||
? 'specialization-2'
|
||||
: 'specialization-1',
|
||||
};
|
||||
if (sql.includes('FROM specialization_ability_skill_chip_links'))
|
||||
return null;
|
||||
if (
|
||||
sql.includes('FROM specialization_abilities WHERE specialization_id=')
|
||||
)
|
||||
return null;
|
||||
if (sql.includes('FROM specialization_ability_attacks')) return null;
|
||||
if (sql.includes('FROM managed_images')) return null;
|
||||
return null;
|
||||
}),
|
||||
all: vi.fn(async () => {
|
||||
if (sql.includes('FROM specialization_ability_skill_chip_links'))
|
||||
return [...mocks.links];
|
||||
return [];
|
||||
}),
|
||||
run: vi.fn(async (...args: unknown[]) => {
|
||||
if (sql.includes('DELETE FROM specialization_ability_skill_chip_links'))
|
||||
mocks.links.length = 0;
|
||||
if (sql.includes('INSERT INTO specialization_ability_skill_chip_links'))
|
||||
mocks.links.push({
|
||||
ability_id: String(args[0]),
|
||||
compatible_ability_id: String(args[1]),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('saves and audits an explicit same-specialization ability pair', async () => {
|
||||
const result = await runSave('ability-2');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
abilitySaved: true,
|
||||
abilityMode: 'edit',
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.links).toEqual([
|
||||
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||
]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'specialization_ability.updated',
|
||||
entityId: 'ability-1',
|
||||
before: expect.objectContaining({ record: expect.any(Object) }),
|
||||
after: expect.objectContaining({
|
||||
skillChipLinks: [
|
||||
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects cross-specialization sharing without writing or auditing', async () => {
|
||||
const result = await runSave('cross-specialization-ability');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 400,
|
||||
data: {
|
||||
abilityError:
|
||||
'Choose an active Skill Chip-compatible ability from the same specialization.',
|
||||
},
|
||||
});
|
||||
expect(mocks.database.exec).not.toHaveBeenCalled();
|
||||
expect(mocks.recordIsacAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the relationship when either ability is archived', async () => {
|
||||
mocks.links.push({
|
||||
ability_id: 'ability-1',
|
||||
compatible_ability_id: 'ability-2',
|
||||
});
|
||||
|
||||
const result = await runArchive();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
abilityArchived: true,
|
||||
resultToken: expect.any(String),
|
||||
});
|
||||
expect(mocks.links).toEqual([]);
|
||||
expect(mocks.recordIsacAudit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'game_data',
|
||||
action: 'specialization_ability.archived',
|
||||
entityId: 'ability-1',
|
||||
before: expect.objectContaining({
|
||||
skillChipLinks: [
|
||||
{ ability_id: 'ability-1', compatible_ability_id: 'ability-2' },
|
||||
],
|
||||
}),
|
||||
after: expect.objectContaining({ skillChipLinks: [] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user