diff --git a/database/schema.sql b/database/schema.sql index 9265ac6..134dd07 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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_idOLD.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(); diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs index f43cfaa..9e0ba25 100644 --- a/scripts/isac-data-export.mjs +++ b/scripts/isac-data-export.mjs @@ -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, diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs index b26737c..9249314 100644 --- a/scripts/isac-data-import.mjs +++ b/scripts/isac-data-import.mjs @@ -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`, diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs index 458f44a..4102841 100644 --- a/scripts/isac-data-tables.mjs +++ b/scripts/isac-data-tables.mjs @@ -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('"', '""')}"`; diff --git a/src/app.css b/src/app.css index 93d7b7c..0210a06 100644 --- a/src/app.css +++ b/src/app.css @@ -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; diff --git a/src/lib/builder/loadout-summary.test.ts b/src/lib/builder/loadout-summary.test.ts index c4428d2..33e484c 100644 --- a/src/lib/builder/loadout-summary.test.ts +++ b/src/lib/builder/loadout-summary.test.ts @@ -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], diff --git a/src/lib/builder/loadout-summary.ts b/src/lib/builder/loadout-summary.ts index aa2a3e6..0331cc9 100644 --- a/src/lib/builder/loadout-summary.ts +++ b/src/lib/builder/loadout-summary.ts @@ -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, diff --git a/src/lib/components/BuilderPicker.svelte b/src/lib/components/BuilderPicker.svelte index d716cc3..4c412bc 100644 --- a/src/lib/components/BuilderPicker.svelte +++ b/src/lib/components/BuilderPicker.svelte @@ -5,7 +5,7 @@ items, value = $bindable(''), label, - placeholder = 'Search available items…', + placeholder = 'Search…', disabledIds = [], onchange }: { @@ -27,19 +27,19 @@ {label} {#if open}
- {#if selected}{/if} + {#if selected}{/if} {#each results as item} - {:else}

NO MATCHING ITEMS

{/each} + {:else}

NO MATCHES

{/each}
{/if} diff --git a/src/lib/components/ItemIcon.svelte b/src/lib/components/ItemIcon.svelte index 511c219..648bcd6 100644 --- a/src/lib/components/ItemIcon.svelte +++ b/src/lib/components/ItemIcon.svelte @@ -14,4 +14,4 @@ const resolvedAlt = $derived(src || !alt ? alt : `${alt} — icon awaiting contribution`); -{resolvedAlt} +{resolvedAlt} diff --git a/src/lib/components/ProposalChangePreview.svelte b/src/lib/components/ProposalChangePreview.svelte index 35e1682..21a8793 100644 --- a/src/lib/components/ProposalChangePreview.svelte +++ b/src/lib/components/ProposalChangePreview.svelte @@ -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' diff --git a/src/lib/components/builder/LoadoutMatrix.svelte b/src/lib/components/builder/LoadoutMatrix.svelte index bc1129f..e926f25 100644 --- a/src/lib/components/builder/LoadoutMatrix.svelte +++ b/src/lib/components/builder/LoadoutMatrix.svelte @@ -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 @@
- LOADOUT MATRIX - {configured ? 'COMBAT READY' : 'CONFIGURATION INCOMPLETE'} + LOADOUT + {configured ? 'COMPLETE' : 'INCOMPLETE'}
- + -
SUPPLIES 3 ABILITIES
+
ABILITIES
{#each abilities as cell} {/each}
- +
{#each weapons as cell}
- +
{#each gear as cell}
- +
-
COMBINED ATTRIBUTES{summary.stats.length}
+
ATTRIBUTES{summary.stats.length}
{#each summary.stats as stat}
{attribute(stat.id)?.name}{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'} {unitValue(stat.value, stat.id)}
{:else} -

Equip and configure items to calculate their known Attributes.

+

Equip items to see known Attributes.

{/each}
-
ACTIVE SET BONUSES{summary.activeBonuses.length}
+
SET BONUSES{summary.activeBonuses.length}
{#each summary.activeBonuses as bonus}
{bonus.name}{bonus.pieces}-PIECE · {bonus.count} EQUIPPED +{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}
{:else} -

Matching Brand Sets and Skill Chips activate bonuses here.

+

No active bonuses.

{/each}
-
EQUIPPED TALENTS{summary.equippedTalents.length}
+
TALENTS{summary.equippedTalents.length}
{#each summary.equippedTalents as equipped}
@@ -84,11 +84,11 @@
{:else} -

Selected equipment Talents appear here.

+

No Talents equipped.

{/each}
-

Includes deterministic equipment values only. Conditional Talent effects remain outside current totals.

+

Known values only. Advanced effects are planned.