From 8991682e591c8e532dc399774f0cc6f89d65170c Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 2 Sep 2026 23:32:42 -0400 Subject: [PATCH] feat: consume selected mechanic revisions --- docs/DESIGN_SYSTEM.md | 28 + src/app.css | 232 ++++++ src/lib/builder/loadout-combat.test.ts | 652 ++++++++++++++-- src/lib/builder/loadout-combat.ts | 336 +++++--- src/lib/builder/loadout-summary.test.ts | 100 ++- src/lib/builder/loadout-summary.ts | 28 +- src/lib/components/DatabaseItemCard.svelte | 44 +- src/lib/components/ItemIconRail.svelte | 7 +- .../database/DatabaseMechanicGroup.svelte | 60 ++ .../DatabaseMechanicStatusBadge.svelte | 12 + src/lib/database/items.test.ts | 60 ++ src/lib/database/items.ts | 48 ++ src/lib/server/build-mechanic-runtime.test.ts | 127 +++ src/lib/server/build-mechanic-runtime.ts | 114 +++ .../server/mechanic-public-projection.test.ts | 302 +++++++ src/lib/server/mechanic-public-projection.ts | 459 +++++++++++ .../server/mechanic-runtime-selection.test.ts | 623 +++++++++++++++ src/lib/server/mechanic-runtime-selection.ts | 735 ++++++++++++++++++ src/lib/server/shared-build-summary.test.ts | 306 ++++++++ src/lib/server/shared-build-summary.ts | 48 ++ src/lib/server/shared-build.test.ts | 258 +++++- src/lib/server/shared-build.ts | 95 ++- src/routes/builder/+page.server.ts | 48 ++ .../builder/builder-runtime-load.test.ts | 282 +++++++ src/routes/database/+page.server.ts | 561 ++++++++++--- src/routes/database/+page.svelte | 23 +- .../database/[category]/+page.server.ts | 616 +++++++++++---- src/routes/database/[category]/+page.svelte | 4 +- .../database/[category]/page.server.test.ts | 674 ++++++++++++++++ src/routes/database/page.server.test.ts | 435 +++++++++++ src/routes/database/public-mechanics.ts | 73 ++ 31 files changed, 6921 insertions(+), 469 deletions(-) create mode 100644 src/lib/components/database/DatabaseMechanicGroup.svelte create mode 100644 src/lib/components/database/DatabaseMechanicStatusBadge.svelte create mode 100644 src/lib/database/items.test.ts create mode 100644 src/lib/server/build-mechanic-runtime.test.ts create mode 100644 src/lib/server/build-mechanic-runtime.ts create mode 100644 src/lib/server/mechanic-public-projection.test.ts create mode 100644 src/lib/server/mechanic-runtime-selection.test.ts create mode 100644 src/lib/server/mechanic-runtime-selection.ts create mode 100644 src/lib/server/shared-build-summary.test.ts create mode 100644 src/routes/builder/builder-runtime-load.test.ts create mode 100644 src/routes/database/[category]/page.server.test.ts create mode 100644 src/routes/database/page.server.test.ts create mode 100644 src/routes/database/public-mechanics.ts diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 10321d6..ae2453c 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -1857,6 +1857,34 @@ responsive card grid. Record cards use the same hierarchy: - bordered fact cells for primary values; - relationship lines and tier descriptions only when applicable. +#### Public mechanic behavior status + +Mechanic owners use the shared integrated card hierarchy. The public DTO carries a +stable group ID, owner type, label and optional context, plus explicitly labeled +variants. Each variant provides public behavior text, `modeled` or `not_modeled` +status, and `model` or `authored` display source. Public cards never receive +lifecycle, readiness, revision, fingerprint, or program data. + +Every owner group displays one textual `MODELED` or `NOT MODELED` badge beside +its identity. `MODELED` uses the model surface, model border, and blue +informational text. `NOT MODELED` uses neutral borders and muted text. Orange +remains reserved for navigation, focus, and actions. Each behavior block labels +its displayed basis as `MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`. + +Talent groups retain Tier I and Tier II variants. Exotic Talents omit Tier II +when the authored source omits it. OS Protocol Talents use a Base variant beside +their existing level and upgrade controls. Skill Chip Talents use Minimum and +Maximum variants in the three-piece section. Specialization tree-rank effects +remain independent groups under their Focus, node, and rank context. + +Compact cards visually clamp long behavior text without removing it from the +accessible name or linked detail. Quick views render the same shared card at a +wider reading measure, retain the binary badge, and add `DISPLAY SOURCE` with +`MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`. Global Database results show the +owner category, record name, current public behavior, binary status, and an +explicit registry navigation label. Status is textual at every breakpoint; +mobile places it after identity and before behavior. + When a record represents an ordered progression rather than independent facts, use the stacked fact treatment. Brand Set bonuses are shown vertically in two-, three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when diff --git a/src/app.css b/src/app.css index 998f9fc..e772c36 100644 --- a/src/app.css +++ b/src/app.css @@ -19404,3 +19404,235 @@ a.secondary:active { grid-template-columns: 1fr; } } + +/* Public mechanic behavior status: Variant A integrated card hierarchy. */ +.database-item-name-line { + min-width: 0; + display: flex; + align-items: flex-start; + gap: 8px; +} +.database-item-name-line > h2 { + min-width: 0; +} +.database-mechanic-status { + flex: none; + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 4px 6px; + border: 1px solid var(--shd-model-border); + background: var(--shd-model-surface); + color: var(--shd-info); + font-size: 10px; + font-weight: 900; + letter-spacing: 0.7px; + line-height: 1; + white-space: nowrap; +} +.database-mechanic-status.not-modeled { + border-color: var(--shd-border-strong); + background: var(--shd-surface-active); + color: var(--shd-text-muted); +} +.database-mechanic-groups { + display: grid; + gap: var(--shd-gap-sm); + margin: 10px 12px 0; +} +.database-mechanic-group { + min-width: 0; + display: grid; + gap: 6px; +} +.database-mechanic-group-context { + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 0.5px; +} +.database-mechanic-group-identity { + min-height: 48px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 9px 10px; + border: 1px solid var(--shd-border); + background: var(--shd-surface-raised); +} +.database-mechanic-group-identity > div { + min-width: 0; + display: grid; + gap: 3px; +} +.database-mechanic-group-identity small, +.database-mechanic-behavior > small, +.database-mechanic-variant-label > span, +.database-mechanic-display-source dt { + color: var(--shd-text-dim); + font-size: 10px; + font-weight: 900; + letter-spacing: 0.8px; + line-height: 1.1; +} +.database-mechanic-group-identity strong { + overflow-wrap: anywhere; + color: var(--shd-text); + font-size: var(--shd-type-supporting); + line-height: 1.25; +} +.database-mechanic-variants { + display: grid; + gap: 6px; +} +.database-mechanic-variant { + min-width: 0; + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + border: 1px solid var(--shd-model-border); + background: var(--shd-model-surface); +} +.database-mechanic-variant[data-status='not_modeled'] { + border-color: var(--shd-border); + background: var(--shd-surface-raised); +} +.database-mechanic-variant-label { + align-self: stretch; + display: grid; + align-content: start; + padding: 10px 8px; + border-right: 1px solid var(--shd-model-border); +} +.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label { + border-color: var(--shd-border); +} +.database-mechanic-variant-label > span { + color: var(--shd-info); +} +.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label > span { + color: var(--shd-text-muted); +} +.database-mechanic-behavior { + min-width: 0; + padding: 10px 12px; + border-left: 2px solid var(--shd-info); +} +.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior { + border-left-color: var(--shd-text-dim); +} +.database-mechanic-behavior > small { + display: block; + color: var(--shd-info); +} +.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior > small { + color: var(--shd-text-muted); +} +.database-mechanic-behavior > p { + margin: 6px 0 0; + color: var(--shd-text); + font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial); +} +.database-mechanic-group:not(.detail) .database-mechanic-behavior > p { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} +.database-mechanic-display-source { + margin: 0; + border: 1px solid var(--shd-border); + background: var(--shd-surface); +} +.database-mechanic-display-source > div { + display: grid; + gap: 4px; + padding: 9px 10px; +} +.database-mechanic-display-source dd { + margin: 0; + color: var(--shd-text-muted); + font-size: var(--shd-type-telemetry); + font-weight: 800; + letter-spacing: 0.5px; +} +.database-specialization-effect-groups { + padding-top: 9px; + border-top: 1px solid var(--shd-border); +} +.database-preview-dialog:has(.database-mechanic-groups) { + width: min(760px, 100%); +} +.database-global-results > div > a { + grid-template-columns: 110px minmax(0, 0.8fr) minmax(120px, 1.35fr) auto auto; + grid-template-areas: 'category identity behavior status navigation'; +} +.database-global-results > div > a > small { + grid-area: category; +} +.database-global-results > div > a > .database-global-result-identity { + grid-area: identity; +} +.database-global-results .database-global-result-behavior { + display: block; + grid-area: behavior; +} +.database-global-results .database-mechanic-status { + grid-area: status; + justify-self: end; +} +.database-global-results .database-global-result-navigation { + grid-area: navigation; + color: var(--shd-orange); +} +@media (max-width: 750px) { + .database-item-name-line { + display: grid; + gap: 7px; + } + .database-item-name-line .database-mechanic-status { + width: max-content; + } + .database-mechanic-variant { + grid-template-columns: 1fr; + } + .database-mechanic-variant-label { + padding: 7px 10px; + border-right: 0; + border-bottom: 1px solid var(--shd-model-border); + } + .database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label { + border-color: var(--shd-border); + } + .database-mechanic-group-identity { + align-items: stretch; + flex-direction: column; + } + .database-mechanic-group-identity .database-mechanic-status { + width: max-content; + } + .database-global-results > div > a { + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'category category' + 'identity navigation' + 'status navigation' + 'behavior behavior'; + } + .database-global-results .database-mechanic-status { + justify-self: start; + } + .database-global-results > div > a:not(:has(.database-mechanic-status)) { + grid-template-areas: + 'category category' + 'identity navigation' + 'behavior behavior'; + } +} +@media (prefers-reduced-motion: reduce) { + .database-mechanic-status, + .database-mechanic-variant { + transition: none; + } +} diff --git a/src/lib/builder/loadout-combat.test.ts b/src/lib/builder/loadout-combat.test.ts index 66e678d..9b01501 100644 --- a/src/lib/builder/loadout-combat.test.ts +++ b/src/lib/builder/loadout-combat.test.ts @@ -10,12 +10,36 @@ import { equippedConditionalProgram, mechanicModifiersAt, skillModifierWindowsForMechanic, + evaluateLoadoutCombat, weaponCriticalChance, } from './loadout-combat.js'; import { calculateSkillDps, type SkillAbilityInput, } from '$lib/calculations/skill'; +import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection'; + +function selectedRuntime( + variants: Record, + options: Partial< + Pick + > = {}, +): MechanicRuntimeSelection { + return { + owner: { type: 'talent', id: 'talent', scope: 'standard', hasTierII: true }, + sourceId: 'source', + sourceFingerprint: 'a'.repeat(64), + revisionId: 'revision', + revisionNumber: 1, + simulationReadiness: options.simulationReadiness ?? 'ready', + scenarioInputs: options.scenarioInputs ?? [], + variants: Object.entries(variants).map(([key, program]) => ({ + key, + label: key, + program, + })), + }; +} describe('loadout mechanic timeline', () => { function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) { @@ -291,82 +315,568 @@ describe('loadout mechanic timeline', () => { ).toEqual([1, 2]); }); - it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => { - const persistedRule = { - rule: { - id: 'tier-i-only', - source_type: 'talent', - source_id: 'exotic-talent', - configured: 1, - script_version: 1, - simulation_readiness: 'ready', - scenario_inputs_json: '[]', - }, - statePools: [], - clauses: [ - { - id: 'shot-clause', - rule_id: 'tier-i-only', - clause_key: 'shot', - label: 'Shot', - trigger_id: null, - event_kind: 'shot_fired', - parameters_json: '{}', - conditions_json: JSON.stringify({ kind: 'true' }), - reducer_json: JSON.stringify({ kind: 'none' }), - emission_kind: 'each_match', - state_pool_id: null, - mutations_json: '[]', - cooldown_seconds: null, - output_branch: 'active', - sort_order: 1, - active: 1, - }, - ], - outcomes: [ - { - id: 'weapon-damage-i', - rule_id: 'tier-i-only', - clause_id: 'shot-clause', - state_pool_id: null, - effect_key: 'Weapon Damage', - variant_key: 'I', - outcome_key: 'weapon-damage', - application_kind: 'active_modifier', - effect_scope: 'equipped_weapon', - stat_attribute_id: 'weapon-damage', - value: 15, - value_expression_json: null, - value_basis: 'flat', - resource_kind: null, - combat_timing: null, - delivery_mode: 'instant', - delivery_duration_seconds: null, - targeting_json: null, - value_scaling: 'flat', - unit: '%', - damage_basis: null, - application_phase: 'on_event', - min_state_value: null, - max_state_value: null, - description_slot: 1, - sort_order: 1, - }, - ], - triggers: [], + it('selects a direct validated Phase 2 program without adapting legacy rows', () => { + const tierTwo: MechanicProgram = { + id: 'mechanic-runtime:["source","II"]', + name: 'Selected Tier II', + clauses: [], }; - const adapted = equippedConditionalProgram({ - key: 'weapon:0:exotic-talent', - tier: 2, - isExotic: true, - rule: persistedRule, + expect( + equippedConditionalProgram({ + key: 'weapon:0:talent', + tier: 2, + isExotic: false, + runtime: selectedRuntime({ I: { ...tierTwo, id: 'tier-I' }, II: tierTwo }), + }), + ).toBe(tierTwo); + }); + + it('interpolates Skill Chip minimum and maximum programs from one selected revision', () => { + const program = (id: string, value: number): MechanicProgram => ({ + id, + name: 'Skill Chip', + clauses: [ + { + id: 'chip-clause', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'chip-outcome', + label: 'Chip outcome', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'all_skills', + value, + basis: 'flat', + attributeId: 'skill-damage', + }, + ], + }, + ], + }); + const runtime = selectedRuntime({ + minimum: program('minimum', 10), + maximum: program('maximum', 40), }); - expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({ - id: 'weapon-damage-i', - value: 15, + const interpolated = equippedConditionalProgram({ + key: 'chips:chip-set:ability', + tier: 2, + isExotic: false, + runtime, }); + + expect(interpolated?.clauses[0].outcomes?.[0]?.value).toBe(30); + expect(interpolated?.id).toBe('minimum'); + }); + + it('interpolates expression-backed outcomes, nested values, and pool durations', () => { + const program = (id: string, value: number): MechanicProgram => ({ + id, + name: 'Skill Chip', + scenarioInputs: [ + { + key: 'target-count', + label: 'Target count', + kind: 'slider', + readiness: 'ready', + defaultValue: value, + bestCaseValue: value + 3, + min: value - 2, + max: value + 7, + }, + ], + pools: [ + { + key: 'chip-pool', + label: 'Chip pool', + scope: 'agent', + initial: value, + min: value - 2, + max: value * 2, + bestCaseValue: value + 2, + expiresAfterSeconds: value - 1, + }, + ], + clauses: [ + { + id: 'chip-clause', + when: 'weapon_hit', + reducer: { + kind: 'periodic', + immediate: true, + intervalSeconds: { + op: 'clamp', + value: { + op: 'add', + left: value, + right: { op: 'floor', value: value + 1 }, + }, + min: value - 1, + max: value * 3, + }, + }, + cooldownSeconds: value, + weight: { + op: 'multiply', + left: value, + right: { + op: 'subtract', + left: { ref: { source: 'scenario', key: 'target-count' } }, + right: value - 1, + }, + }, + mutations: [ + { + kind: 'add', + pool: 'chip-pool', + value: { + op: 'multiply', + left: value, + right: { op: 'add', left: value, right: 1 }, + }, + }, + ], + outcomes: [ + { + id: 'chip-outcome', + label: 'Chip outcome', + phase: 'while_active', + kind: 'attribute_modifier', + scope: 'all_skills', + value: { + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'add', + left: value, + right: { op: 'floor', value: value + 1 }, + }, + min: value - 1, + max: value * 3, + }, + right: { + ref: { source: 'attribute', attributeId: 'skill-damage' }, + }, + }, + basis: 'flat', + attributeId: 'skill-damage', + statePool: 'chip-pool', + perStack: true, + durationSeconds: value, + minStacks: value - 1, + maxStacks: value * 2, + probabilityPercent: value * 10, + }, + ], + }, + ], + }); + const runtime = selectedRuntime({ + minimum: program('minimum', 3), + maximum: program('maximum', 15), + }); + + const interpolated = equippedConditionalProgram({ + key: 'chips:chip-set:ability', + tier: 1, + isExotic: false, + runtime, + }); + + expect(interpolated?.scenarioInputs?.[0]).toMatchObject({ + defaultValue: 7, + bestCaseValue: 10, + min: 5, + max: 14, + }); + expect(interpolated?.pools?.[0]).toMatchObject({ + initial: 7, + min: 5, + max: 14, + bestCaseValue: 9, + expiresAfterSeconds: 6, + }); + expect(interpolated?.clauses[0]).toMatchObject({ + cooldownSeconds: 7, + reducer: { + kind: 'periodic', + immediate: true, + intervalSeconds: { + op: 'clamp', + value: { + op: 'add', + left: 7, + right: { op: 'floor', value: 8 }, + }, + min: 6, + max: 21, + }, + }, + mutations: [ + { + value: { + op: 'multiply', + left: 7, + right: { op: 'add', left: 7, right: 1 }, + }, + }, + ], + }); + expect(interpolated?.clauses[0].outcomes?.[0]).toMatchObject({ + value: { + op: 'multiply', + left: { + op: 'clamp', + value: { + op: 'add', + left: 7, + right: { op: 'floor', value: 8 }, + }, + min: 6, + max: 21, + }, + right: { + ref: { source: 'attribute', attributeId: 'skill-damage' }, + }, + }, + attributeId: 'skill-damage', + statePool: 'chip-pool', + perStack: true, + durationSeconds: 7, + minStacks: 6, + maxStacks: 14, + probabilityPercent: 70, + }); + }); + + it('returns exact endpoint programs and retains quality-count clamping', () => { + const program = (id: string, value: number): MechanicProgram => ({ + id, + name: 'Skill Chip', + clauses: [ + { + id: 'chip-clause', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'chip-outcome', + label: 'Chip outcome', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'all_skills', + value, + basis: 'flat', + attributeId: 'skill-damage', + }, + ], + }, + ], + }); + const minimum = program('minimum', 10); + const maximum = program('maximum', 40); + const runtime = selectedRuntime({ minimum, maximum }); + const selected = (tier: number) => + equippedConditionalProgram({ + key: 'chips:chip-set:ability', + tier, + isExotic: false, + runtime, + }); + + expect(selected(-1)).toBe(minimum); + expect(selected(0)).toBe(minimum); + expect(selected(3)).toBe(maximum); + expect(selected(4)).toBe(maximum); + }); + + it('withholds Skill Chip execution when minimum and maximum graphs differ', () => { + const minimum: MechanicProgram = { + id: 'minimum', + name: 'Skill Chip', + clauses: [ + { + id: 'chip-clause', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'chip-outcome', + label: 'Chip outcome', + phase: 'on_event', + kind: 'attribute_modifier', + scope: 'all_skills', + value: { + op: 'multiply', + left: 10, + right: { + ref: { source: 'attribute', attributeId: 'skill-damage' }, + }, + }, + basis: 'flat', + attributeId: 'skill-damage', + }, + ], + }, + ], + }; + const maximum: MechanicProgram = { + ...minimum, + id: 'maximum', + clauses: [ + { + ...minimum.clauses[0], + outcomes: [ + { + ...minimum.clauses[0].outcomes![0], + value: { + op: 'multiply', + left: 40, + right: { + ref: { source: 'attribute', attributeId: 'skill-duration' }, + }, + }, + }, + ], + }, + ], + }; + + const selected = ( + runtime: MechanicRuntimeSelection, + tier = 2, + ) => + equippedConditionalProgram({ + key: 'chips:chip-set:ability', + tier, + isExotic: false, + runtime, + }); + + expect(selected(selectedRuntime({ minimum }))).toBeNull(); + expect(selected(selectedRuntime({ maximum }))).toBeNull(); + expect(selected(selectedRuntime({ minimum, maximum }), 0)).toBeNull(); + expect(selected(selectedRuntime({ minimum, maximum }))).toBeNull(); + expect(selected(selectedRuntime({ minimum, maximum }), 3)).toBeNull(); + + const extraOutcomeMaximum: MechanicProgram = { + ...minimum, + id: 'maximum-extra-outcome', + clauses: [ + { + ...minimum.clauses[0], + outcomes: [ + ...minimum.clauses[0].outcomes!, + { ...minimum.clauses[0].outcomes![0], id: 'additional-outcome' }, + ], + }, + ], + }; + expect( + selected(selectedRuntime({ minimum, maximum: extraOutcomeMaximum })), + ).toBeNull(); + }); + + it('falls back to Tier I only for an exotic selected revision that omits Tier II', () => { + const tierOne: MechanicProgram = { + id: 'mechanic-runtime:["exotic-source","I"]', + name: 'Selected Tier I', + clauses: [], + }; + const runtime = selectedRuntime({ I: tierOne }); + + expect( + equippedConditionalProgram({ + key: 'weapon:0:exotic-talent', + tier: 2, + isExotic: true, + runtime, + }), + ).toBe(tierOne); + expect( + equippedConditionalProgram({ + key: 'weapon:0:standard-talent', + tier: 2, + isExotic: false, + runtime, + }), + ).toBeNull(); + }); + + it('does not execute an unselected or stale legacy payload', () => { + const staleHistorical = { + key: 'weapon:0:talent', + tier: 1, + isExotic: false, + runtime: null, + rule: { clauses: [{ outcomes: [{ value: 999 }] }] }, + }; + + expect(equippedConditionalProgram(staleHistorical)).toBeNull(); + }); +}); + +describe('requires-assumptions mechanic gating', () => { + const program: MechanicProgram = { + id: 'assumed-direct-damage', + name: 'Assumed direct damage', + scenarioInputs: [ + { + key: 'assumed-direct-damage', + label: 'Assumed direct damage', + kind: 'number', + readiness: 'requires_assumption', + min: 1, + max: 100, + }, + ], + clauses: [ + { + id: 'damage-on-hit', + when: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + id: 'assumed-damage', + label: 'Assumed damage', + phase: 'on_event', + kind: 'direct_damage', + scope: 'equipped_weapon', + basis: 'flat', + value: { + ref: { source: 'scenario', key: 'assumed-direct-damage' }, + }, + }, + ], + }, + ], + }; + + function fixture(inputs?: Record) { + const draft = createDraft(); + for (const weapon of draft.weapons) { + weapon.itemId = 'weapon'; + weapon.damage = 100; + } + for (const slot of Object.keys(draft.gear)) draft.gear[slot].itemId = 'gear'; + for (const chip of draft.chips) chip.setId = 'chip'; + draft.protocol.itemId = 'protocol'; + draft.specialization.focusId = 'focus'; + draft.conditionalEffects['weapon:0:assumed'] = { + mode: 'encounter', + ...(inputs ? { inputs } : {}), + }; + + const runtime = selectedRuntime( + { I: program }, + { + simulationReadiness: 'requires_assumptions', + scenarioInputs: program.scenarioInputs, + }, + ); + return { + draft, + catalog: { + attributes: [ + { + id: 'weapon-damage', + name: 'Weapon Damage', + calculation_type: 'Additive', + unit: '%', + cap_value: null, + }, + ], + gear: [], + gearAugments: [], + brands: [], + weapons: [ + { + id: 'weapon', + name: 'Weapon', + category: 'Assault Rifle', + rpm: 60, + magazine: 100, + reload_speed: 1, + }, + ], + talents: [], + chipSets: [], + protocols: [], + specializations: [ + { + focuses: [ + { + id: 'focus', + abilities: [{ id: 'ability-1' }, { id: 'ability-2' }, { id: 'ability-3' }], + nodes: [], + }, + ], + }, + ], + calculation: { + revision: { id: 'revision', version: 1 }, + stages: [], + profiles: [ + { + weapon_category: '*', + stage_key: 'weapon_damage', + stat_attribute_id: 'weapon-damage', + weight: 1, + }, + ], + }, + }, + summary: { + stats: [{ id: 'weapon-damage', value: 0, sourceCount: 0 }], + abilityStats: [], + activeBonuses: [], + equippedTalents: [ + { + key: 'weapon:0:assumed', + id: 'assumed', + runtime, + tier: 1, + isExotic: false, + source: 'weapon', + description: '', + custom: { name: 'Assumed direct damage' }, + }, + ], + }, + }; + } + + it('blocks combat output when an active requires-assumptions mechanic has no caller input', () => { + const { draft, catalog, summary } = fixture(); + const primary = evaluateLoadoutCombat(draft, catalog, summary)[0]; + + expect(primary.status).toBe('insufficient'); + if (primary.status === 'insufficient') { + expect(primary.missing).toContain( + 'Conditional mechanic Assumed direct damage requires explicit valid scenario inputs: Assumed direct damage', + ); + } + }); + + it('executes a requires-assumptions mechanic when the caller supplies a valid input', () => { + const { draft, catalog, summary } = fixture({ + 'assumed-direct-damage': 50, + }); + const primary = evaluateLoadoutCombat(draft, catalog, summary)[0]; + + expect(primary.status).toBe('complete'); + if (primary.status === 'complete') { + expect(primary.effectSummaries?.[0]?.activationCount).toBeGreaterThan(0); + expect(primary.encounterDps).toBeGreaterThan(100); + } }); }); diff --git a/src/lib/builder/loadout-combat.ts b/src/lib/builder/loadout-combat.ts index 7980f3f..bc8ce57 100644 --- a/src/lib/builder/loadout-combat.ts +++ b/src/lib/builder/loadout-combat.ts @@ -14,9 +14,8 @@ import { type MechanicEvent, type MechanicOutcome, type MechanicProgram, - type NumericExpression, } from '$lib/calculations/mechanic'; -import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence'; +import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection'; import { calculateSkillDps, skillActivationTimes, @@ -192,103 +191,157 @@ export function weaponCriticalChance( ); } -function conditionalProgram( - rule: any, +function selectedRuntimeProgram( + runtime: MechanicRuntimeSelection | null | undefined, variantKey: string, - valueScale = 1, ): MechanicProgram | null { - if (!rule?.rule) return null; - const adapted = adaptConditionalEffectProgram(rule, { variantKey }); - if ( - !adapted.program || - adapted.status === 'INVALID' || - adapted.status === 'CAPTURED_ONLY' - ) - return null; - if (valueScale === 1) return adapted.program; - const scale = (value: NumericExpression): NumericExpression => - typeof value === 'number' ? value * valueScale : value; - return { - ...adapted.program, - clauses: adapted.program.clauses.map((clause) => ({ - ...clause, - outcomes: clause.outcomes?.map((outcome) => ({ - ...outcome, - value: scale(outcome.value), - })), - })), - }; + return runtime?.variants.find((variant) => variant.key === variantKey)?.program ?? null; } -function interpolatedConditionalProgram( - rule: any, +function hasMechanicProgram( + source: T, +): source is T & { program: MechanicProgram } { + return source.program !== null; +} + +type InterpolatedMechanicValue = { value: unknown }; + +function isMechanicDefinitionRecord( + value: unknown, +): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Interpolates numeric leaves only when the entire mechanic definition graph + * matches. Non-numeric values carry mechanic identity and semantics, so a + * mismatch withholds the program rather than combining different mechanics. + */ +function interpolateMechanicValue( + minimum: unknown, + maximum: unknown, + ratio: number, +): InterpolatedMechanicValue | null { + if (typeof minimum === 'number' || typeof maximum === 'number') { + if ( + typeof minimum !== 'number' || + typeof maximum !== 'number' || + !Number.isFinite(minimum) || + !Number.isFinite(maximum) + ) + return null; + return { value: minimum + (maximum - minimum) * ratio }; + } + if (Object.is(minimum, maximum)) return { value: minimum }; + if (Array.isArray(minimum) || Array.isArray(maximum)) { + if ( + !Array.isArray(minimum) || + !Array.isArray(maximum) || + minimum.length !== maximum.length + ) + return null; + const values: unknown[] = []; + for (let index = 0; index < minimum.length; index += 1) { + const interpolated = interpolateMechanicValue( + minimum[index], + maximum[index], + ratio, + ); + if (!interpolated) return null; + values.push(interpolated.value); + } + return { value: values }; + } + if ( + !isMechanicDefinitionRecord(minimum) || + !isMechanicDefinitionRecord(maximum) + ) + return null; + const minimumKeys = Object.keys(minimum).sort(); + const maximumKeys = Object.keys(maximum).sort(); + if ( + minimumKeys.length !== maximumKeys.length || + minimumKeys.some((key, index) => key !== maximumKeys[index]) + ) + return null; + const entries: [string, unknown][] = []; + for (const key of minimumKeys) { + const interpolated = interpolateMechanicValue( + minimum[key], + maximum[key], + ratio, + ); + if (!interpolated) return null; + entries.push([key, interpolated.value]); + } + return { value: Object.fromEntries(entries) }; +} + +function interpolateMechanicProgram( + minimum: MechanicProgram, + maximum: MechanicProgram, + ratio: number, +): MechanicProgram | null { + const { id: minimumId, ...minimumDefinition } = minimum; + const { id: _maximumId, ...maximumDefinition } = maximum; + const interpolated = interpolateMechanicValue( + minimumDefinition, + maximumDefinition, + ratio, + ); + if (!interpolated || !isMechanicDefinitionRecord(interpolated.value)) + return null; + return { id: minimumId, ...interpolated.value } as MechanicProgram; +} + +function interpolatedRuntimeProgram( + runtime: MechanicRuntimeSelection | null | undefined, minimumVariant: string, maximumVariant: string, step: number, totalSteps: number, ): MechanicProgram | null { - const minimum = conditionalProgram(rule, minimumVariant); - const maximum = conditionalProgram(rule, maximumVariant); - if (!minimum || !maximum) return minimum ?? maximum; + const minimum = selectedRuntimeProgram(runtime, minimumVariant); + const maximum = selectedRuntimeProgram(runtime, maximumVariant); + if (!minimum || !maximum) return null; const ratio = Math.max( 0, Math.min(1, Number(step) / Math.max(1, Number(totalSteps))), ); - const maximumByKey = new Map( - maximum.clauses.flatMap((clause) => - (clause.outcomes ?? []).map( - (outcome) => [`${clause.id}:${outcome.id}`, outcome] as const, - ), - ), - ); - return { - ...minimum, - clauses: minimum.clauses.map((clause) => ({ - ...clause, - outcomes: clause.outcomes?.map((outcome) => { - const upper = maximumByKey.get(`${clause.id}:${outcome.id}`); - return upper && - typeof outcome.value === 'number' && - typeof upper.value === 'number' - ? { - ...outcome, - value: outcome.value + (upper.value - outcome.value) * ratio, - } - : outcome; - }), - })), - }; + if (!Number.isFinite(ratio)) return null; + const interpolated = interpolateMechanicProgram(minimum, maximum, ratio); + if (!interpolated) return null; + if (ratio === 0) return minimum; + if (ratio === 1) return maximum; + return interpolated; } export function equippedConditionalProgram( - equipped: any, + equipped: Pick< + LoadoutSummary['equippedTalents'][number], + 'key' | 'runtime' | 'tier' | 'isExotic' + >, ): MechanicProgram | null { - if (String(equipped.key).startsWith('chips:')) { - return interpolatedConditionalProgram( - equipped.rule, + if (equipped.key.startsWith('chips:')) { + return interpolatedRuntimeProgram( + equipped.runtime, 'minimum', 'maximum', Number(equipped.tier), 3, ); } - if (String(equipped.key).startsWith('protocol:')) { - return conditionalProgram(equipped.rule, 'base'); + if (equipped.key.startsWith('protocol:')) { + return selectedRuntimeProgram(equipped.runtime, 'base'); } const equipmentTalent = - String(equipped.key).startsWith('weapon:') || - String(equipped.key).startsWith('gear:'); - const variant = equipmentTalent - ? Number(equipped.tier) >= 2 - ? 'II' - : 'I' - : 'base'; - return ( - conditionalProgram(equipped.rule, variant) ?? + equipped.key.startsWith('weapon:') || equipped.key.startsWith('gear:'); + const variant = equipmentTalent && Number(equipped.tier) >= 2 ? 'II' : 'I'; + const selected = selectedRuntimeProgram(equipped.runtime, variant); + return selected ?? (equipmentTalent && equipped.isExotic && variant === 'II' - ? conditionalProgram(equipped.rule, 'I') - : null) - ); + ? selectedRuntimeProgram(equipped.runtime, 'I') + : null); } export type ActiveMechanicEvaluation = { @@ -299,15 +352,58 @@ export type ActiveMechanicEvaluation = { events?: MechanicEvent[]; }; +function configuredScenarioInputValues(configured: unknown) { + return configured && typeof configured === 'object' && !Array.isArray(configured) + ? (configured as Record) + : {}; +} + +function requiredScenarioInputs(program: MechanicProgram) { + const inputs = program.scenarioInputs ?? []; + const assumptionInputs = inputs.filter( + (input) => input.readiness === 'requires_assumption', + ); + return assumptionInputs.length ? assumptionInputs : inputs; +} + +function isValidScenarioInputValue( + input: NonNullable[number], + value: unknown, +) { + if (input.kind === 'toggle') return typeof value === 'boolean'; + if (typeof value !== 'number' || !Number.isFinite(value)) return false; + if (input.min !== undefined && value < input.min) return false; + if (input.max !== undefined && value > input.max) return false; + return true; +} + +function unresolvedScenarioInputs( + program: MechanicProgram, + configured: unknown, + simulationReadiness: MechanicRuntimeSelection['simulationReadiness'], +) { + if (simulationReadiness !== 'requires_assumptions') return []; + const inputs = requiredScenarioInputs(program); + if (!inputs.length) return ['no supported required scenario inputs']; + const values = configuredScenarioInputValues(configured); + return inputs.flatMap((input) => { + if (!Object.prototype.hasOwnProperty.call(values, input.key)) + return [input.label]; + return isValidScenarioInputValue(input, values[input.key]) + ? [] + : [`${input.label} (invalid value)`]; + }); +} + function mechanicScenarioInputs( program: MechanicProgram, configured: unknown, maximum = false, -) { - const values = - configured && typeof configured === 'object' && !Array.isArray(configured) - ? (configured as Record) - : {}; +): Record { + const values = configuredScenarioInputValues(configured) as Record< + string, + number | boolean + >; return Object.fromEntries( (program.scenarioInputs ?? []).map((input) => [ input.key, @@ -322,22 +418,59 @@ function mechanicScenarioInputs( ); } +function mechanicIsEnabled( + state: Pick, + calculationMode: LoadoutDraft['calculationMode'], +) { + return calculationMode === 'maximum' || state.mode !== 'off'; +} + +function unavailableMechanicEvidence( + sources: ReadonlyArray<{ + key: string; + name: string; + program: MechanicProgram; + simulationReadiness: MechanicRuntimeSelection['simulationReadiness']; + }>, + draft: LoadoutDraft, +) { + return sources.flatMap((source) => { + const state = draft.conditionalEffects[source.key] ?? { mode: 'off' }; + if (!mechanicIsEnabled(state, draft.calculationMode)) return []; + const inputs = unresolvedScenarioInputs( + source.program, + state.inputs, + source.simulationReadiness, + ); + return inputs.length + ? [ + `Conditional mechanic ${source.name} requires explicit valid scenario inputs: ${inputs.join(', ')}`, + ] + : []; + }); +} + function evaluateConfiguredMechanic( program: MechanicProgram, buildEvents: ( scenarioInputs: Record, ) => MechanicEvent[], - state: any, + state: LoadoutDraft['conditionalEffects'][string], profile: LoadoutDraft['combatProfile'], weaponBaseDamage: number, maxHealth: number, calculationMode: LoadoutDraft['calculationMode'], equipmentContext = { protocolTalentUpgrades: 0 }, attributes: Record = {}, + simulationReadiness: MechanicRuntimeSelection['simulationReadiness'] = 'ready', ): ActiveMechanicEvaluation | null { - if (calculationMode !== 'maximum' && state.mode === 'off') return null; + if (!mechanicIsEnabled(state, calculationMode)) return null; + if (unresolvedScenarioInputs(program, state.inputs, simulationReadiness).length) + return null; const encounterSeconds = Math.max(1, Number(profile.encounterSeconds)); - const maximum = calculationMode === 'maximum' || state.mode === 'peak'; + const maximum = + simulationReadiness !== 'requires_assumptions' && + (calculationMode === 'maximum' || state.mode === 'peak'); const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum); const events = buildEvents(scenarioInputs); const result = evaluateMechanicProgram( @@ -1045,7 +1178,7 @@ function skillModifierWindows( catalog: Catalog, summary: LoadoutSummary, allowedAttributeIds: Set, -): SkillModifierWindow[] { +): { windows: SkillModifierWindow[]; unavailable: string[] } { const focus = selectedFocus(draft, catalog); const combatEffects = selectedNodeRanks(draft, catalog).flatMap( ({ node, rank }: any) => @@ -1058,21 +1191,25 @@ function skillModifierWindows( const sources = [ ...summary.equippedTalents.map((equipped: any) => ({ key: String(equipped.key), + name: String(equipped.custom?.name ?? 'Talent'), abilityId: equipped.abilityId ? String(equipped.abilityId) : null, equipmentContext: { protocolTalentUpgrades: String(equipped.key).startsWith('protocol:') ? Number(equipped.tier) : 0, }, + simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready', program: equippedConditionalProgram(equipped), })), ...combatEffects.map((effect: any) => ({ key: String(effect.id), + name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'), abilityId: effect.abilityId, equipmentContext: { protocolTalentUpgrades: 0 }, - program: conditionalProgram(effect.rule, 'base'), + simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready', + program: selectedRuntimeProgram(effect.runtime, 'base'), })), - ].filter((source) => source.program); + ].filter(hasMechanicProgram); const primarySelection = draft.weapons[0]; const primaryWeapon = catalog.weapons.find( (weapon) => weapon.id === primarySelection.itemId, @@ -1123,6 +1260,7 @@ function skillModifierWindows( draft.calculationMode, source.equipmentContext, baseAttributes, + source.simulationReadiness, ); return evaluation ? [{ ...source, evaluation }] : []; }); @@ -1138,7 +1276,10 @@ function skillModifierWindows( ); } - return windows; + return { + windows, + unavailable: unavailableMechanicEvidence(sources, draft), + }; } export function evaluateLoadoutCombat( @@ -1166,6 +1307,7 @@ export function evaluateLoadoutCombat( ? Number(equipped.tier) : 0, }, + simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready', program: equippedConditionalProgram(equipped), })), ...combatEffects.map((effect: any) => ({ @@ -1174,9 +1316,10 @@ export function evaluateLoadoutCombat( effect.definition?.name ?? effect.nodeName ?? 'Specialization effect', ), equipmentContext: { protocolTalentUpgrades: 0 }, - program: conditionalProgram(effect.rule, 'base'), + simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready', + program: selectedRuntimeProgram(effect.runtime, 'base'), })), - ].filter((source) => source.program); + ].filter(hasMechanicProgram); const skillDeploymentEvents = loadoutSkillDeploymentEvents( draft, catalog, @@ -1201,6 +1344,7 @@ export function evaluateLoadoutCombat( missing.push( `${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`, ); + missing.push(...unavailableMechanicEvidence(conditionalSources, draft)); return missing; }; @@ -1251,6 +1395,7 @@ export function evaluateLoadoutCombat( draft.calculationMode, source.equipmentContext, criticalAttributes, + source.simulationReadiness, ); return evaluation ? [{ ...source, state, evaluation }] : []; }); @@ -1418,6 +1563,21 @@ export function evaluateLoadoutSkillCombat( .map((stat) => [stat.id, stat.value]), ); const abilities = loadoutSkillAbilities(draft, catalog); + const mechanicWindows = skillModifierWindows( + draft, + catalog, + summary, + boundIds, + ); + if (mechanicWindows.unavailable.length) { + return { + status: 'INSUFFICIENT_DATA', + revisionId: revisionId || null, + missing: mechanicWindows.unavailable, + abilities: [], + combined: null, + }; + } return calculateSkillDps({ rules: { revisionId, bindings }, abilities, @@ -1440,7 +1600,7 @@ export function evaluateLoadoutSkillCombat( ]; }), ), - modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds), + modifierWindows: mechanicWindows.windows, assumptions: { encounterSeconds: draft.combatProfile.encounterSeconds, targetCount: Math.max( diff --git a/src/lib/builder/loadout-summary.test.ts b/src/lib/builder/loadout-summary.test.ts index 33e484c..a54c3e4 100644 --- a/src/lib/builder/loadout-summary.test.ts +++ b/src/lib/builder/loadout-summary.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection'; import { createDraft } from './loadout.js'; import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js'; @@ -35,6 +36,24 @@ const catalog = (overrides: Partial = {}): LoadoutCatalog => ({ const stat = (summary: ReturnType, id: string) => summary.stats.find((entry) => entry.id === id); +const selectedRuntime = ( + sourceId: string, + variantKeys: string[], +): MechanicRuntimeSelection => ({ + owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true }, + sourceId, + sourceFingerprint: 'a'.repeat(64), + revisionId: `${sourceId}-revision`, + revisionNumber: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + variants: variantKeys.map((key) => ({ + key, + label: key, + program: { id: `${sourceId}:${key}`, name: sourceId, clauses: [] }, + })), +}); + describe('summarizeLoadout', () => { it('returns an empty deterministic summary for a partial draft', () => { expect(summarizeLoadout(createDraft(), catalog())).toEqual({ @@ -193,7 +212,6 @@ describe('summarizeLoadout', () => { max_value: 45, }, ], - rule: null, }, }, ], @@ -399,7 +417,6 @@ describe('summarizeLoadout', () => { name: 'Recovery', description_template: 'Restore {1}.', effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }], - rule: null, }, }, ], @@ -536,19 +553,6 @@ describe('summarizeLoadout', () => { exoticTalentTier: 1, }); - const rule = { - clauses: [ - { - outcomes: [ - { - kind: 'attribute_modifier', - attributeId: 'talent-stat', - value: 100, - }, - ], - }, - ], - }; const summary = summarizeLoadout( draft, catalog({ @@ -585,7 +589,6 @@ describe('summarizeLoadout', () => { ], }, ], - rule, }, { id: 'talent-two', @@ -627,7 +630,7 @@ describe('summarizeLoadout', () => { tier: 1, tierLabel: 'TIER I', description: 'Gain 10% for 2 Shots.', - rule, + runtime: null, }), expect.objectContaining({ id: 'talent-two', @@ -645,6 +648,69 @@ describe('summarizeLoadout', () => { expect(stat(summary, 'talent-stat')).toBeUndefined(); }); + it('carries selected runtime capabilities without retaining legacy rule payloads', () => { + const draft = createDraft(); + draft.gear.Holster.itemId = 'gear'; + draft.gear.Holster.talentId = 'talent'; + for (const chip of draft.chips.slice(0, 3)) chip.setId = 'chip-set'; + draft.protocol.itemId = 'protocol'; + + const talentRuntime = selectedRuntime('talent-source', ['I', 'II']); + const chipRuntime = selectedRuntime('chip-source', ['minimum', 'maximum']); + const protocolRuntime = selectedRuntime('protocol-source', ['base']); + const summary = summarizeLoadout( + draft, + catalog({ + gear: [{ id: 'gear', tier: 0, base_armor_min: null }], + talents: [ + { + id: 'talent', + name: 'Talent', + runtime: talentRuntime, + template: 'Talent', + tiers: [{ tier: 'I', effects: [] }], + }, + ], + chipSets: [ + { + id: 'chip-set', + name: 'Chip', + specialization_ability_id: 'ability', + talent: { + name: 'Chip Talent', + runtime: chipRuntime, + description_template: 'Chip', + effects: [], + }, + }, + ], + protocols: [ + { + id: 'protocol', + name: 'Protocol', + talent: { + name: 'Protocol Talent', + runtime: protocolRuntime, + description_template: 'Protocol', + effects: [], + }, + }, + ], + }), + ); + + expect(summary.equippedTalents.map((equipped) => equipped.runtime)).toEqual([ + talentRuntime, + chipRuntime, + protocolRuntime, + ]); + expect( + summary.equippedTalents.every( + (equipped) => !Object.hasOwn(equipped, 'rule'), + ), + ).toBe(true); + }); + it('uses the authored tier description when a Talent has no template row', () => { const draft = createDraft(); draft.weapons[0].itemId = 'weapon'; diff --git a/src/lib/builder/loadout-summary.ts b/src/lib/builder/loadout-summary.ts index 0331cc9..28b08aa 100644 --- a/src/lib/builder/loadout-summary.ts +++ b/src/lib/builder/loadout-summary.ts @@ -4,6 +4,7 @@ import { type LoadoutDraft, type TalentTier, } from '$lib/builder/loadout'; +import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection'; export type LoadoutCatalog = { attributes: any[]; @@ -38,7 +39,18 @@ export type LoadoutSummary = { }>; }>; activeBonuses: any[]; - equippedTalents: any[]; + equippedTalents: Array<{ + key: string; + id: string; + runtime: MechanicRuntimeSelection | null; + tier: number; + tierLabel?: string; + isExotic?: boolean; + abilityId?: string | null; + source: string; + description: string; + custom?: { name?: string }; + }>; }; const coreKeys = ['firepower', 'toughness', 'engineering'] as const; @@ -164,7 +176,7 @@ export function summarizeLoadout( )?.id ?? ''; const brandCounts = new Map(); const activeBonuses: any[] = []; - const equippedTalents: any[] = []; + const equippedTalents: LoadoutSummary['equippedTalents'] = []; const abilityValues = new Map>(); const abilitySources = new Map< string, @@ -260,9 +272,9 @@ export function summarizeLoadout( equippedTalents.push({ key: `gear:${slot}:${selection.talentId}`, id: selection.talentId, - rule: + runtime: catalog.talents.find((entry) => entry.id === selection.talentId) - ?.rule ?? null, + ?.runtime ?? null, tier: selection.talentTier, tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I', isExotic: false, @@ -336,9 +348,9 @@ export function summarizeLoadout( equippedTalents.push({ key: `weapon:${index}:${equippedTalent.id}`, id: equippedTalent.id, - rule: + runtime: catalog.talents.find((entry) => entry.id === equippedTalent.id) - ?.rule ?? null, + ?.runtime ?? null, tier: equippedTalent.tier, tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I', isExotic: equippedTalent.isExotic, @@ -434,7 +446,7 @@ export function summarizeLoadout( key: `chips:${group.item.id}:${group.abilityId}`, id: '', custom: group.item.talent, - rule: group.item.talent.rule ?? null, + runtime: group.item.talent.runtime ?? null, tier: group.highEnd, abilityId: group.abilityId || null, source: '3 Skill Chips', @@ -486,7 +498,7 @@ export function summarizeLoadout( key: `protocol:${protocol.id}`, id: '', custom: protocol.talent, - rule: protocol.talent.rule ?? null, + runtime: protocol.talent.runtime ?? null, tier: draft.protocol.talentUpgrades, source: 'OS Protocol', description: protocol.talent.description_template.replace( diff --git a/src/lib/components/DatabaseItemCard.svelte b/src/lib/components/DatabaseItemCard.svelte index 679d4fe..ef0d59e 100644 --- a/src/lib/components/DatabaseItemCard.svelte +++ b/src/lib/components/DatabaseItemCard.svelte @@ -7,15 +7,20 @@ import type { DatabaseItem } from '$lib/database/items'; import MarkdownContent from './MarkdownContent.svelte'; import WeaponModSlotIcon from './WeaponModSlotIcon.svelte'; + import DatabaseMechanicGroupCard from './database/DatabaseMechanicGroup.svelte'; + import DatabaseMechanicStatusBadge from './database/DatabaseMechanicStatusBadge.svelte'; + import { databaseMechanicGroupStatus, type DatabaseMechanicGroup } from '$lib/database/items'; let { item, hideDiscussionUtility = false, - anchor = true + anchor = true, + detail = false }: { item: DatabaseItem & { gameIcon?: GameIconName }; hideDiscussionUtility?: boolean; anchor?: boolean; + detail?: boolean; } = $props(); let protocolLevel = $state(1); let talentUpgrades = $state(0); @@ -29,6 +34,14 @@ const effect = item.protocolPreview?.talentEffects[Number(raw) - 1]; return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder; }); + const mechanicGroups = () => item.mechanicGroups ?? []; + const hasMechanicGroups = () => mechanicGroups().length > 0; + const isSpecializationMechanic = (group: DatabaseMechanicGroup) => group.ownerType === 'specialization_tree_rank'; + const hasSpecializationMechanics = () => mechanicGroups().some(isSpecializationMechanic); + const primaryMechanicGroup = () => { + const groups = mechanicGroups(); + return groups.length === 1 && !isSpecializationMechanic(groups[0]) ? groups[0] : null; + };
@@ -39,7 +52,12 @@
{item.kicker} -

{item.name}

+
+

{item.name}

+ {#if primaryMechanicGroup()} + + {/if} +
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
@@ -53,7 +71,7 @@ {#if item.protocolPreview}
{item.protocolPreview.primaryStat}{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}
- {#if item.protocolPreview.talentDescription} + {#if item.protocolPreview.talentDescription && !hasMechanicGroups()}

{talentDescription()}

{/if}
{#if item.protocolPreview.talentDescription}
{#each [1, 2, 3, 4, 5] as upgrade}{/each}
{/if}
@@ -86,15 +104,29 @@ {/each}
{/if} - {#if item.variant === 'talent'} + {#if hasMechanicGroups() && !hasSpecializationMechanics()} +
+ {#each mechanicGroups() as group (group.id)} + + {/each} +
+ {/if} + {#if item.variant === 'talent' && !hasMechanicGroups()}
TIER I

{item.description ?? 'Description is not available.'}

-
TIER II

{item.secondaryDescription ?? 'Description is not available.'}

+ {#if item.secondaryDescription}
TIER II

{item.secondaryDescription}

{/if}
- {:else if !item.protocolPreview} + {:else if !item.protocolPreview && (!hasMechanicGroups() || hasSpecializationMechanics())} {#if item.description}

{item.description}

{/if} {#if item.secondaryDescription}

{item.secondaryDescription}

{/if} {/if} + {#if hasSpecializationMechanics()} +
+ {#each mechanicGroups().filter(isSpecializationMechanic) as group (group.id)} + + {/each} +
+ {/if} {#if item.afterLines?.length}
{#each item.afterLines as line}
{line.label}{line.value}
{/each}
{/if} diff --git a/src/lib/components/ItemIconRail.svelte b/src/lib/components/ItemIconRail.svelte index e637084..95cdcc8 100644 --- a/src/lib/components/ItemIconRail.svelte +++ b/src/lib/components/ItemIconRail.svelte @@ -18,6 +18,7 @@ let closeButton = $state(null); let dialog = $state(null); const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => { + if (!item.preview) return; selected = item; trigger = button; await tick(); @@ -32,8 +33,8 @@ }; const trapDialogFocus = (event: KeyboardEvent) => { if (event.key !== 'Tab' || !dialog) return; - const focusable = [...dialog.querySelectorAll('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')] - .filter((element) => !element.hasAttribute('hidden')); + const focusable = [...dialog.querySelectorAll('a[href],button:not(:disabled),input:not([type="hidden"]):not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex="-1"])')] + .filter((element) => !element.hasAttribute('hidden') && element.getClientRects().length > 0); const first = focusable[0]; const last = focusable.at(-1); if (!first || !last) return; @@ -65,7 +66,7 @@ {/if} diff --git a/src/lib/components/database/DatabaseMechanicGroup.svelte b/src/lib/components/database/DatabaseMechanicGroup.svelte new file mode 100644 index 0000000..3a0b00c --- /dev/null +++ b/src/lib/components/database/DatabaseMechanicGroup.svelte @@ -0,0 +1,60 @@ + + +
+ {#if showIdentity} +
+
+ {group.label} + {#if group.context}{group.context}{/if} +
+ +
+ {:else if group.context} +

{group.context}

+ {/if} + +
+ {#each group.variants as variant (variant.label)} +
+
{variant.label}
+
+ {variant.status === 'modeled' ? 'MODELED BEHAVIOR' : 'AUTHORED DESCRIPTION'} +

{variant.behaviorText}

+
+
+ {/each} +
+ + {#if detail} +
+
DISPLAY SOURCE
{displaySource}
+
+ {/if} +
diff --git a/src/lib/components/database/DatabaseMechanicStatusBadge.svelte b/src/lib/components/database/DatabaseMechanicStatusBadge.svelte new file mode 100644 index 0000000..1334f8d --- /dev/null +++ b/src/lib/components/database/DatabaseMechanicStatusBadge.svelte @@ -0,0 +1,12 @@ + + + + {databaseMechanicStatusLabel(status)} + diff --git a/src/lib/database/items.test.ts b/src/lib/database/items.test.ts new file mode 100644 index 0000000..c58c1eb --- /dev/null +++ b/src/lib/database/items.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + databaseMechanicGroupDisplaySourceLabel, + databaseMechanicGroupStatus, + databaseMechanicStatusLabel, + type DatabaseMechanicGroup, +} from './items'; + +const group = ( + variants: DatabaseMechanicGroup['variants'], +): DatabaseMechanicGroup => ({ + id: 'owner-1', + ownerType: 'talent', + label: 'Weapon Talent', + variants, +}); + +describe('database mechanic presentation', () => { + it('uses one modeled status and display source for a fully modeled owner', () => { + const value = group([ + { + label: 'Tier I', + behaviorText: 'Gain 10% Weapon Damage.', + status: 'modeled', + displaySource: 'model', + }, + { + label: 'Tier II', + behaviorText: 'Gain 20% Weapon Damage.', + status: 'modeled', + displaySource: 'model', + }, + ]); + + expect(databaseMechanicGroupStatus(value)).toBe('modeled'); + expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('MODELED'); + expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('MODELED BEHAVIOR'); + }); + + it('falls back to the neutral public status when an owner is incomplete', () => { + const value = group([ + { + label: 'Tier I', + behaviorText: 'Gain 10% Weapon Damage.', + status: 'modeled', + displaySource: 'model', + }, + { + label: 'Tier II', + behaviorText: 'Gain 20% Weapon Damage.', + status: 'not_modeled', + displaySource: 'authored', + }, + ]); + + expect(databaseMechanicGroupStatus(value)).toBe('not_modeled'); + expect(databaseMechanicStatusLabel(databaseMechanicGroupStatus(value))).toBe('NOT MODELED'); + expect(databaseMechanicGroupDisplaySourceLabel(value)).toBe('AUTHORED DESCRIPTION'); + }); +}); diff --git a/src/lib/database/items.ts b/src/lib/database/items.ts index 147ea86..72d3a78 100644 --- a/src/lib/database/items.ts +++ b/src/lib/database/items.ts @@ -2,6 +2,53 @@ import type { GameIconName } from '$lib/game-icons'; import type { GameDataTargetType } from '$lib/game-data-targets'; export type DatabaseBadge = { label: string; tone?: string }; + +/** Public, serializable mechanic presentation for one source owner. */ +export type DatabaseMechanicOwnerType = + | 'talent' + | 'os_protocol_talent' + | 'skill_chip_talent' + | 'specialization_tree_rank'; +export type DatabaseMechanicStatus = 'modeled' | 'not_modeled'; +export type DatabaseMechanicDisplaySource = 'model' | 'authored'; +export type DatabaseMechanicVariant = { + label: string; + behaviorText: string; + status: DatabaseMechanicStatus; + displaySource: DatabaseMechanicDisplaySource; +}; +export type DatabaseMechanicGroup = { + /** Stable owner identity for keyed rendering and deep-linkable detail. */ + id: string; + ownerType: DatabaseMechanicOwnerType; + label: string; + context?: string; + variants: DatabaseMechanicVariant[]; +}; + +export const databaseMechanicStatusLabel = (status: DatabaseMechanicStatus) => + status === 'modeled' ? 'MODELED' : 'NOT MODELED'; + +/** A complete public owner projection has one status across its variants. */ +export const databaseMechanicGroupStatus = ( + group: DatabaseMechanicGroup, +): DatabaseMechanicStatus => + group.variants.length > 0 && + group.variants.every((variant) => variant.status === 'modeled') + ? 'modeled' + : 'not_modeled'; + +export const databaseMechanicGroupDisplaySourceLabel = ( + group: DatabaseMechanicGroup, +) => + databaseMechanicGroupStatus(group) === 'modeled' + ? 'MODELED BEHAVIOR' + : 'AUTHORED DESCRIPTION'; + +export const isDatabaseMechanicStatus = ( + value: unknown, +): value is DatabaseMechanicStatus => + value === 'modeled' || value === 'not_modeled'; export type DatabaseMeter = { label: string; value: number }; export type DatabaseWeaponModSlot = { slot: string; @@ -43,6 +90,7 @@ export type DatabaseItem = { afterLines?: DatabaseFact[]; iconGroups?: DatabaseIconGroup[]; weaponModSlots?: DatabaseWeaponModSlot[]; + mechanicGroups?: DatabaseMechanicGroup[]; description?: string; secondaryDescription?: string; descriptionStyle?: 'prose' | 'data'; diff --git a/src/lib/server/build-mechanic-runtime.test.ts b/src/lib/server/build-mechanic-runtime.test.ts new file mode 100644 index 0000000..d9a8aac --- /dev/null +++ b/src/lib/server/build-mechanic-runtime.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Database } from '@tursodatabase/database'; +import type { MechanicRuntimeSelection } from './mechanic-runtime-selection'; + +const mocks = vi.hoisted(() => ({ + loadSelections: vi.fn(), +})); + +vi.mock('./mechanic-runtime-selection', async (importOriginal) => ({ + ...(await importOriginal()), + loadSelectedMechanicRuntimesForOwnerReferences: mocks.loadSelections, +})); + +import { loadBuildMechanicRuntimes } from './build-mechanic-runtime'; +import { mechanicRuntimeOwnerReferenceKey } from './mechanic-runtime-selection'; + +const runtime = (sourceId: string): MechanicRuntimeSelection => ({ + owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true }, + sourceId, + sourceFingerprint: 'a'.repeat(64), + revisionId: `${sourceId}-revision`, + revisionNumber: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + variants: [ + { + key: 'base', + label: 'BASE', + program: { id: `mechanic-runtime:${sourceId}:base`, name: sourceId, clauses: [] }, + }, + ], +}); + +function emptyDatabase() { + return {} as Database; +} + +describe('Build mechanic runtime hydration', () => { + beforeEach(() => { + mocks.loadSelections.mockReset(); + }); + + it('maps every owner family through the selected-runtime reader using attachment ownership', async () => { + const database = emptyDatabase(); + const selections = new Map([ + [ + mechanicRuntimeOwnerReferenceKey({ + type: 'talent', + id: 'talent-1', + }), + runtime('talent-source'), + ], + [ + mechanicRuntimeOwnerReferenceKey({ + type: 'os_protocol_talent', + id: 'protocol-talent-1', + }), + runtime('protocol-source'), + ], + [ + mechanicRuntimeOwnerReferenceKey({ + type: 'skill_chip_talent', + id: 'chip-talent-1', + }), + runtime('chip-source'), + ], + [ + mechanicRuntimeOwnerReferenceKey({ + type: 'specialization_tree_rank', + id: 'attachment-1', + }), + runtime('attachment-source'), + ], + ]); + mocks.loadSelections.mockResolvedValueOnce(selections); + + const hydrated = await loadBuildMechanicRuntimes(database, { + talentIds: ['talent-1', 'not-authored'], + protocolTalentIds: ['protocol-talent-1'], + skillChipTalentIds: ['chip-talent-1'], + specializationAttachmentIds: ['attachment-1'], + }); + + expect(mocks.loadSelections).toHaveBeenCalledWith(database, [ + { type: 'talent', id: 'talent-1' }, + { type: 'talent', id: 'not-authored' }, + { type: 'os_protocol_talent', id: 'protocol-talent-1' }, + { type: 'skill_chip_talent', id: 'chip-talent-1' }, + { type: 'specialization_tree_rank', id: 'attachment-1' }, + ]); + expect(hydrated).toMatchObject({ + talents: { + 'talent-1': { sourceId: 'talent-source' }, + 'not-authored': null, + }, + protocolTalents: { + 'protocol-talent-1': { sourceId: 'protocol-source' }, + }, + skillChipTalents: { 'chip-talent-1': { sourceId: 'chip-source' } }, + specializationAttachments: { + 'attachment-1': { sourceId: 'attachment-source' }, + }, + }); + expect( + hydrated.specializationAttachments['combat-effect-reusable'], + ).toBeUndefined(); + expect(JSON.parse(JSON.stringify(hydrated))).toEqual(hydrated); + }); + + it('returns null capabilities when no selected source can be loaded', async () => { + const database = emptyDatabase(); + const hydrated = await loadBuildMechanicRuntimes(database, { + talentIds: ['talent-1'], + protocolTalentIds: ['protocol-talent-1'], + skillChipTalentIds: ['chip-talent-1'], + specializationAttachmentIds: ['attachment-1'], + }); + + expect(hydrated).toEqual({ + talents: { 'talent-1': null }, + protocolTalents: { 'protocol-talent-1': null }, + skillChipTalents: { 'chip-talent-1': null }, + specializationAttachments: { 'attachment-1': null }, + }); + expect(mocks.loadSelections).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/lib/server/build-mechanic-runtime.ts b/src/lib/server/build-mechanic-runtime.ts new file mode 100644 index 0000000..ad8166c --- /dev/null +++ b/src/lib/server/build-mechanic-runtime.ts @@ -0,0 +1,114 @@ +import type { Database } from '@tursodatabase/database'; +import { + loadSelectedMechanicRuntimesForOwnerReferences, + mechanicRuntimeOwnerReferenceKey, + type MechanicRuntimeSelection, +} from './mechanic-runtime-selection'; + +export type BuildMechanicRuntimeOwnerIds = { + talentIds: readonly string[]; + protocolTalentIds: readonly string[]; + skillChipTalentIds: readonly string[]; + specializationAttachmentIds: readonly string[]; +}; + +export type BuildMechanicRuntimes = { + talents: Record; + protocolTalents: Record; + skillChipTalents: Record; + specializationAttachments: Record; +}; + +function uniqueNonEmptyIds(ids: readonly string[]) { + return [...new Set(ids.filter((id) => id.trim().length > 0))]; +} + +function emptyRuntimeMap(ids: readonly string[]) { + return Object.fromEntries(ids.map((id) => [id, null])) as Record< + string, + MechanicRuntimeSelection | null + >; +} + +/** + * Hydrates the only executable capability exposed to Build surfaces. One + * bounded bulk read resolves source metadata, selected revisions, graph rows, + * and active attributes. Every owner still receives an independent null when + * its source cannot be validated. + */ +export async function loadBuildMechanicRuntimes( + database: Database, + input: BuildMechanicRuntimeOwnerIds, +): Promise { + const talentIds = uniqueNonEmptyIds(input.talentIds); + const protocolTalentIds = uniqueNonEmptyIds(input.protocolTalentIds); + const skillChipTalentIds = uniqueNonEmptyIds(input.skillChipTalentIds); + const specializationAttachmentIds = uniqueNonEmptyIds( + input.specializationAttachmentIds, + ); + const runtimes: BuildMechanicRuntimes = { + talents: emptyRuntimeMap(talentIds), + protocolTalents: emptyRuntimeMap(protocolTalentIds), + skillChipTalents: emptyRuntimeMap(skillChipTalentIds), + specializationAttachments: emptyRuntimeMap(specializationAttachmentIds), + }; + const owners = [ + ...talentIds.map((id) => ({ type: 'talent' as const, id })), + ...protocolTalentIds.map((id) => ({ + type: 'os_protocol_talent' as const, + id, + })), + ...skillChipTalentIds.map((id) => ({ + type: 'skill_chip_talent' as const, + id, + })), + ...specializationAttachmentIds.map((id) => ({ + type: 'specialization_tree_rank' as const, + id, + })), + ]; + if (!owners.length) return runtimes; + + try { + const selections = await loadSelectedMechanicRuntimesForOwnerReferences( + database, + owners, + ); + for (const id of talentIds) { + runtimes.talents[id] = + selections.get(mechanicRuntimeOwnerReferenceKey({ type: 'talent', id })) ?? + null; + } + for (const id of protocolTalentIds) { + runtimes.protocolTalents[id] = + selections.get( + mechanicRuntimeOwnerReferenceKey({ + type: 'os_protocol_talent', + id, + }), + ) ?? null; + } + for (const id of skillChipTalentIds) { + runtimes.skillChipTalents[id] = + selections.get( + mechanicRuntimeOwnerReferenceKey({ + type: 'skill_chip_talent', + id, + }), + ) ?? null; + } + for (const id of specializationAttachmentIds) { + runtimes.specializationAttachments[id] = + selections.get( + mechanicRuntimeOwnerReferenceKey({ + type: 'specialization_tree_rank', + id, + }), + ) ?? null; + } + } catch { + // An unavailable metadata read has no executable fallback at this boundary. + } + + return runtimes; +} diff --git a/src/lib/server/mechanic-public-projection.test.ts b/src/lib/server/mechanic-public-projection.test.ts new file mode 100644 index 0000000..8b3c77c --- /dev/null +++ b/src/lib/server/mechanic-public-projection.test.ts @@ -0,0 +1,302 @@ +import type { + MechanicAuthoredOwner, + MechanicAuthoredSource, +} from '$lib/calculations/mechanic-authored-description'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources'; +import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions'; +import { + loadMechanicPublicProjectionForOwner, + loadMechanicPublicProjectionsForOwners, + mechanicPublicProjectionOwnerKey, + refreshMechanicPublicProjectionInTransaction, + type MechanicPublicProjectionOwnerRef, +} from './mechanic-public-projection'; + +let database: Database; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const requiredVariantKeys = ( + owner: MechanicPublicProjectionOwnerRef, +): readonly string[] => { + switch (owner.type) { + case 'talent': + return ['I', 'II']; + case 'os_protocol_talent': + case 'specialization_tree_rank': + return ['base']; + case 'skill_chip_talent': + return ['minimum', 'maximum']; + } +}; + +const authoredOwner = ( + owner: MechanicPublicProjectionOwnerRef, +): MechanicAuthoredOwner => + owner.type === 'talent' ? { ...owner, scope: 'standard' } : owner; + +const sourceFor = ( + owner: MechanicPublicProjectionOwnerRef, +): MechanicAuthoredSource => ({ + version: 1, + owner: authoredOwner(owner), + template: 'Gain {1} output.', + variants: requiredVariantKeys(owner).map((key, index) => ({ + key, + materialized: `Gain ${String(index + 1)} output.`, + slots: [{ key: '1', value: String(index + 1), order: 0 }], + })), + ...(owner.type === 'specialization_tree_rank' + ? { + specialization: { + rankDescription: 'Rank effect description.', + effectId: 'effect-1', + effectTemplate: 'Gain {1} output.', + }, + } + : {}), +}); + +const modeledDraftFor = ( + owner: MechanicPublicProjectionOwnerRef, +): ConditionalMechanicDraftV1 => ({ + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'mark-target', + label: 'Mark target', + event: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + key: 'mark', + label: 'Mark', + phase: 'on_event', + kind: 'marker', + scope: 'target', + basis: 'flat', + variants: Object.fromEntries( + requiredVariantKeys(owner).map((key) => [key, { value: 1 }]), + ), + }, + ], + }, + ], +}); + +const createProjection = async ( + owner: MechanicPublicProjectionOwnerRef, + options: { modeled?: boolean } = {}, +) => { + const source = await saveMechanicAuthoredSourceInTransaction( + database, + sourceFor(owner), + { expectedSourceFingerprint: null }, + ); + if (options.modeled) { + await saveMechanicModelRevisionInTransaction( + database, + source, + modeledDraftFor(owner), + { programName: 'Target mark', origin: 'test', actorId: null }, + ); + } + await refreshMechanicPublicProjectionInTransaction(database, source); + return source; +}; + +const execute = async (sql: string, ...parameters: unknown[]) => + (await database.prepare(sql)).run(...parameters); + +const databaseWithPreparedQueryCount = () => { + let queryCount = 0; + const reader = new Proxy(database, { + get(target, property, receiver) { + if (property !== 'prepare') { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + return async (sql: string) => { + queryCount += 1; + return target.prepare(sql); + }; + }, + }) as unknown as Database; + return { reader, queryCount: () => queryCount }; +}; + +describe('mechanic public projection owner readers', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + }); + + afterEach(async () => { + await database.close(); + }); + + it('creates distinct typed keys for every owner identity', () => { + const owners: MechanicPublicProjectionOwnerRef[] = [ + { type: 'talent', id: 'shared' }, + { type: 'os_protocol_talent', id: 'shared' }, + { type: 'skill_chip_talent', id: 'shared' }, + { type: 'specialization_tree_rank', id: 'shared' }, + ]; + const keys = owners.map(mechanicPublicProjectionOwnerKey); + + expect(new Set(keys)).toHaveLength(owners.length); + expect(mechanicPublicProjectionOwnerKey(owners[0])).not.toBe( + mechanicPublicProjectionOwnerKey({ + type: 'talent', + id: 'os_protocol_talent:shared', + }), + ); + }); + + it('loads one owner in authored variant order', async () => { + const owner = { type: 'skill_chip_talent', id: 'chip-1' } as const; + await createProjection(owner); + + const projection = await loadMechanicPublicProjectionForOwner( + database, + owner, + ); + + expect(projection.map((variant) => variant.variantKey)).toEqual([ + 'minimum', + 'maximum', + ]); + expect(projection.map((variant) => variant.behaviorText)).toEqual([ + 'Gain 1 output.', + 'Gain 2 output.', + ]); + }); + + it('keeps missing owners empty without suppressing other projections', async () => { + const present = { type: 'talent', id: 'talent-1' } as const; + const missing = { type: 'os_protocol_talent', id: 'missing' } as const; + await createProjection(present); + + const projections = await loadMechanicPublicProjectionsForOwners(database, [ + present, + missing, + ]); + + expect( + projections[mechanicPublicProjectionOwnerKey(present)].map( + (variant) => variant.variantKey, + ), + ).toEqual(['I', 'II']); + expect(projections[mechanicPublicProjectionOwnerKey(missing)]).toEqual([]); + }); + + it('excludes one corrupt projection without suppressing a valid owner', async () => { + const valid = { type: 'os_protocol_talent', id: 'protocol-1' } as const; + const corrupt = { type: 'specialization_tree_rank', id: 'rank-1' } as const; + const corruptSource = await createProjection(corrupt); + await createProjection(valid); + await execute( + "UPDATE mechanic_public_variants SET behavior_text='forged text' WHERE source_id=?", + corruptSource.id, + ); + + const projections = await loadMechanicPublicProjectionsForOwners(database, [ + corrupt, + valid, + ]); + + expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]); + expect( + projections[mechanicPublicProjectionOwnerKey(valid)].map( + (variant) => variant.behaviorText, + ), + ).toEqual(['Gain 1 output.']); + }); + + it('uses a bounded query snapshot for many modeled owners', async () => { + const owners = Array.from({ length: 8 }, (_, index) => ({ + type: 'talent' as const, + id: `talent-${String(index + 1)}`, + })); + for (const owner of owners) { + await createProjection(owner, { modeled: true }); + } + const { reader, queryCount } = databaseWithPreparedQueryCount(); + + const projections = await loadMechanicPublicProjectionsForOwners( + reader, + owners, + ); + + expect(queryCount()).toBeLessThanOrEqual(10); + for (const owner of owners) { + expect( + projections[mechanicPublicProjectionOwnerKey(owner)], + ).toMatchObject([ + { variantKey: 'I', displaySource: 'model' }, + { variantKey: 'II', displaySource: 'model' }, + ]); + } + }); + + it('isolates a corrupt selected revision while preserving another modeled owner', async () => { + const valid = { type: 'talent', id: 'valid' } as const; + const corrupt = { type: 'talent', id: 'corrupt' } as const; + const validSource = await createProjection(valid, { modeled: true }); + const corruptSource = await createProjection(corrupt, { modeled: true }); + await execute( + 'UPDATE mechanic_model_revisions SET program_hash=? WHERE source_id=?', + 'b'.repeat(64), + corruptSource.id, + ); + + const owners = [corrupt, valid]; + const projections = await loadMechanicPublicProjectionsForOwners( + database, + owners, + ); + const oneOwnerProjections = Object.fromEntries( + await Promise.all( + owners.map(async (owner) => [ + mechanicPublicProjectionOwnerKey(owner), + await loadMechanicPublicProjectionForOwner(database, owner), + ]), + ), + ); + + expect(projections).toEqual(oneOwnerProjections); + expect(projections[mechanicPublicProjectionOwnerKey(corrupt)]).toEqual([]); + expect(projections[mechanicPublicProjectionOwnerKey(valid)]).toMatchObject([ + { + sourceId: validSource.id, + variantKey: 'I', + displaySource: 'model', + }, + { + sourceId: validSource.id, + variantKey: 'II', + displaySource: 'model', + }, + ]); + }); +}); diff --git a/src/lib/server/mechanic-public-projection.ts b/src/lib/server/mechanic-public-projection.ts index f2c67b8..0d6a3da 100644 --- a/src/lib/server/mechanic-public-projection.ts +++ b/src/lib/server/mechanic-public-projection.ts @@ -10,7 +10,9 @@ import { type MechanicAttributeLabelResolver, } from '$lib/calculations/mechanic-public-renderer'; import type { Database } from '@tursodatabase/database'; +import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description'; import { + loadMechanicAuthoredSource, loadMechanicAuthoredSourceById, type MechanicAuthoredSourceRecord, } from './mechanic-authored-sources'; @@ -37,6 +39,50 @@ export type MechanicPublicProjection = { variants: MechanicPublicVariantRecord[]; }; +/** Stable public identity for a mechanic owner, without authoring-only fields. */ +export type MechanicPublicProjectionOwnerRef = { + [Type in MechanicAuthoredOwner['type']]: Pick< + Extract, + 'type' | 'id' + >; +}[MechanicAuthoredOwner['type']]; + +declare const mechanicPublicProjectionOwnerKeyBrand: unique symbol; + +/** A serializable, collision-safe map key derived from an owner type and ID. */ +export type MechanicPublicProjectionOwnerKey = string & { + readonly [mechanicPublicProjectionOwnerKeyBrand]: true; +}; + +export type MechanicPublicProjectionsByOwner = Record< + MechanicPublicProjectionOwnerKey, + MechanicPublicVariantRecord[] +>; + +/** + * Length-prefixes each part so owner IDs cannot collide with another owner + * type or ID when projections are stored in a route-loader-safe object. + */ +export function mechanicPublicProjectionOwnerKey( + owner: MechanicPublicProjectionOwnerRef, +): MechanicPublicProjectionOwnerKey { + return `${owner.type.length}:${owner.type}${owner.id.length}:${owner.id}` as MechanicPublicProjectionOwnerKey; +} + +function isMechanicPublicProjectionOwnerRef( + value: unknown, +): value is MechanicPublicProjectionOwnerRef { + if (typeof value !== 'object' || value === null) return false; + const owner = value as Record; + return ( + isNonEmptyString(owner.id) && + (owner.type === 'talent' || + owner.type === 'os_protocol_talent' || + owner.type === 'skill_chip_talent' || + owner.type === 'specialization_tree_rank') + ); +} + /** Rebuilds one owner's complete public projection in the caller transaction. */ export async function refreshMechanicPublicProjectionInTransaction( database: Database, @@ -146,6 +192,356 @@ function isValidTimestamp(value: unknown): value is string { return isNonEmptyString(value) && !Number.isNaN(Date.parse(value)); } +type MechanicPublicProjectionSnapshotRow = Record; + +type MechanicPublicProjectionSnapshot = { + sourceByOwner: Map; + sourceById: Map; + variantsBySourceId: Map; + slotsBySourceId: Map; + publicVariantsBySourceId: Map; + selectionBySourceId: Map; + selectedRevisionBySourceId: Map; + poolsByRevisionId: Map; + clausesByRevisionId: Map; + outcomesByRevisionId: Map; + activeAttributes: MechanicPublicProjectionSnapshotRow[]; +}; + +function ownerSnapshotKey(type: string, id: string) { + return `${type.length}:${type}${id.length}:${id}`; +} + +function mapRowsByStringColumn( + rows: readonly MechanicPublicProjectionSnapshotRow[], + column: string, +) { + const result = new Map(); + for (const row of rows) { + const value = row[column]; + if (typeof value !== 'string') continue; + result.set(value, [...(result.get(value) ?? []), row]); + } + return result; +} + +function mapFirstRowByStringColumn( + rows: readonly MechanicPublicProjectionSnapshotRow[], + column: string, +) { + const result = new Map(); + for (const row of rows) { + const value = row[column]; + if (typeof value === 'string' && !result.has(value)) { + result.set(value, row); + } + } + return result; +} + +function copiedRow(row: MechanicPublicProjectionSnapshotRow | undefined) { + return row === undefined ? undefined : { ...row }; +} + +function copiedRows(rows: MechanicPublicProjectionSnapshotRow[] | undefined) { + return (rows ?? []).map((row) => ({ ...row })); +} + +async function loadMechanicPublicProjectionSnapshotRows( + database: Database, + sql: string, + ids: readonly string[], +): Promise { + return (await ( + await database.prepare(sql) + ).all(JSON.stringify(ids))) as MechanicPublicProjectionSnapshotRow[]; +} + +/** + * Reads every table the single-owner validator needs in a bounded number of + * queries. The returned rows are private input to that validator, never a + * public projection by themselves. + */ +async function loadMechanicPublicProjectionSnapshot( + database: Database, + owners: readonly MechanicPublicProjectionOwnerRef[], +): Promise { + const requestedOwners = [ + ...new Map( + owners + .filter(isMechanicPublicProjectionOwnerRef) + .map((owner) => [ownerSnapshotKey(owner.type, owner.id), owner]), + ).values(), + ]; + const sourceRows = (await ( + await database.prepare(`SELECT source.* + FROM mechanic_authored_sources source + JOIN json_each(?) requested + ON source.owner_type=json_extract(requested.value,'$.type') + AND source.owner_id=json_extract(requested.value,'$.id')`) + ).all( + JSON.stringify(requestedOwners), + )) as MechanicPublicProjectionSnapshotRow[]; + const sourceByOwner = new Map(); + for (const source of sourceRows) { + if ( + typeof source.owner_type === 'string' && + typeof source.owner_id === 'string' + ) { + sourceByOwner.set( + ownerSnapshotKey(source.owner_type, source.owner_id), + source, + ); + } + } + const sourceById = mapFirstRowByStringColumn(sourceRows, 'id'); + const sourceIds = [...sourceById.keys()]; + if (!sourceIds.length) { + return { + sourceByOwner, + sourceById, + variantsBySourceId: new Map(), + slotsBySourceId: new Map(), + publicVariantsBySourceId: new Map(), + selectionBySourceId: new Map(), + selectedRevisionBySourceId: new Map(), + poolsByRevisionId: new Map(), + clausesByRevisionId: new Map(), + outcomesByRevisionId: new Map(), + activeAttributes: [], + }; + } + + const [ + variantRows, + slotRows, + publicVariantRows, + selectionRows, + revisionRows, + activeAttributes, + ] = await Promise.all([ + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_authored_variants + WHERE source_id IN (SELECT value FROM json_each(?)) + ORDER BY source_id,sort_order`, + sourceIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_authored_slots + WHERE source_id IN (SELECT value FROM json_each(?)) + ORDER BY source_id,variant_key,sort_order`, + sourceIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_public_variants + WHERE source_id IN (SELECT value FROM json_each(?))`, + sourceIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT source_id,revision_id FROM mechanic_model_selections + WHERE source_id IN (SELECT value FROM json_each(?))`, + sourceIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT revision.* + FROM mechanic_model_selections selection + JOIN mechanic_model_revisions revision + ON revision.id=selection.revision_id + AND revision.source_id=selection.source_id + WHERE selection.source_id IN (SELECT value FROM json_each(?))`, + sourceIds, + ), + (async () => + (await ( + await database.prepare( + 'SELECT id,name FROM stat_attributes WHERE active=1', + ) + ).all()) as MechanicPublicProjectionSnapshotRow[])(), + ]); + + const revisionBySourceId = mapFirstRowByStringColumn( + revisionRows, + 'source_id', + ); + const revisionIds = [ + ...new Set( + revisionRows.flatMap((revision) => + typeof revision.id === 'string' ? [revision.id] : [], + ), + ), + ]; + const [poolRows, clauseRows, outcomeRows] = revisionIds.length + ? await Promise.all([ + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_pools + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order`, + revisionIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_clauses + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order`, + revisionIds, + ), + loadMechanicPublicProjectionSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_outcomes + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order,variant_key`, + revisionIds, + ), + ]) + : [[], [], []]; + + return { + sourceByOwner, + sourceById, + variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'), + slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'), + publicVariantsBySourceId: mapRowsByStringColumn( + publicVariantRows, + 'source_id', + ), + selectionBySourceId: mapFirstRowByStringColumn(selectionRows, 'source_id'), + selectedRevisionBySourceId: revisionBySourceId, + poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'), + clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'), + outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'), + activeAttributes, + }; +} + +/** + * Replays the exact read shapes used by the single-owner boundary against a + * private, immutable-in-practice bulk snapshot. Reusing those readers keeps + * source parsing, revision parsing, hashes, lifecycle checks, rendering, and + * cache validation identical for one and many owners. + */ +function mechanicPublicProjectionSnapshotDatabase( + snapshot: MechanicPublicProjectionSnapshot, +): Database { + return { + prepare: async (sql: string) => { + const normalizedSql = sql.replace(/\s+/g, ' ').trim(); + const sourceId = (parameters: unknown[]) => + typeof parameters[0] === 'string' ? parameters[0] : ''; + const rowForOwner = (parameters: unknown[]) => { + const [ownerType, ownerId] = parameters; + return typeof ownerType === 'string' && typeof ownerId === 'string' + ? snapshot.sourceByOwner.get(ownerSnapshotKey(ownerType, ownerId)) + : undefined; + }; + const selectedRevision = (parameters: unknown[]) => + snapshot.selectedRevisionBySourceId.get(sourceId(parameters)); + + return { + get: async (...parameters: unknown[]) => { + if ( + normalizedSql === + 'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?' + ) { + return copiedRow(rowForOwner(parameters)); + } + if ( + normalizedSql === + 'SELECT * FROM mechanic_authored_sources WHERE id=?' + ) { + return copiedRow(snapshot.sourceById.get(sourceId(parameters))); + } + if ( + normalizedSql === + 'SELECT revision_id FROM mechanic_model_selections WHERE source_id=?' + ) { + return copiedRow( + snapshot.selectionBySourceId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith( + 'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ', + ) + ) { + return copiedRow(selectedRevision(parameters)); + } + throw new Error( + `Unsupported mechanic public projection snapshot query: ${normalizedSql}`, + ); + }, + all: async (...parameters: unknown[]) => { + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_authored_variants ', + ) + ) { + return copiedRows( + snapshot.variantsBySourceId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ') + ) { + return copiedRows( + snapshot.slotsBySourceId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql === + 'SELECT * FROM mechanic_public_variants WHERE source_id=?' + ) { + return copiedRows( + snapshot.publicVariantsBySourceId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql === + 'SELECT id,name FROM stat_attributes WHERE active=1' + ) { + return copiedRows(snapshot.activeAttributes); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_pools ', + ) + ) { + return copiedRows( + snapshot.poolsByRevisionId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_clauses ', + ) + ) { + return copiedRows( + snapshot.clausesByRevisionId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_outcomes ', + ) + ) { + return copiedRows( + snapshot.outcomesByRevisionId.get(sourceId(parameters)), + ); + } + throw new Error( + `Unsupported mechanic public projection snapshot query: ${normalizedSql}`, + ); + }, + }; + }, + } as unknown as Database; +} + /** * Loads only a complete, internally consistent projection. The projection is * cached text, so a malformed or partially replaced cache must never become a @@ -371,6 +767,69 @@ export async function loadMechanicPublicProjection( } } +/** + * Loads one owner's projection only after resolving its source identity and + * passing it through the complete fail-closed projection validator. + */ +export async function loadMechanicPublicProjectionForOwner( + database: Database, + owner: MechanicPublicProjectionOwnerRef, +): Promise { + try { + if (!isMechanicPublicProjectionOwnerRef(owner)) return []; + const source = await loadMechanicAuthoredSource( + database, + owner.type, + owner.id, + ); + if ( + !source || + source.source.owner.type !== owner.type || + source.source.owner.id !== owner.id + ) { + return []; + } + return await loadMechanicPublicProjection(database, source.id); + } catch { + return []; + } +} + +/** + * Loads independent, validated public projections for route loaders. Missing + * or corrupt owners retain an empty entry without suppressing valid owners. + */ +export async function loadMechanicPublicProjectionsForOwners( + database: Database, + owners: readonly MechanicPublicProjectionOwnerRef[], +): Promise { + const ownerKeys = owners.map(mechanicPublicProjectionOwnerKey); + const emptyProjections = Object.fromEntries( + ownerKeys.map((key) => [key, []]), + ) as MechanicPublicProjectionsByOwner; + if (!owners.length) return emptyProjections; + + try { + const snapshot = await loadMechanicPublicProjectionSnapshot( + database, + owners, + ); + const snapshotDatabase = mechanicPublicProjectionSnapshotDatabase(snapshot); + const entries = await Promise.all( + owners.map(async (owner, index) => { + const projection = await loadMechanicPublicProjectionForOwner( + snapshotDatabase, + owner, + ); + return [ownerKeys[index], projection] as const; + }), + ); + return Object.fromEntries(entries) as MechanicPublicProjectionsByOwner; + } catch { + return emptyProjections; + } +} + export function mechanicPublicProjectionSnapshot( selected: MechanicModelRevisionRecord | null, projection: MechanicPublicVariantRecord[], diff --git a/src/lib/server/mechanic-runtime-selection.test.ts b/src/lib/server/mechanic-runtime-selection.test.ts new file mode 100644 index 0000000..cee49d0 --- /dev/null +++ b/src/lib/server/mechanic-runtime-selection.test.ts @@ -0,0 +1,623 @@ +import type { + MechanicAuthoredOwner, + MechanicAuthoredSource, +} from '$lib/calculations/mechanic-authored-description'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { saveMechanicAuthoredSourceInTransaction } from './mechanic-authored-sources'; +import { saveMechanicModelRevisionInTransaction } from './mechanic-model-revisions'; +import { + loadSelectedMechanicRuntime, + loadSelectedMechanicRuntimes, + mechanicRuntimeOwnerKey, + mechanicRuntimeProgramId, +} from './mechanic-runtime-selection'; + +let database: Database; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +function variantKeys(owner: MechanicAuthoredOwner) { + switch (owner.type) { + case 'talent': + return owner.scope === 'standard' || owner.hasTierII + ? ['I', 'II'] + : ['I']; + case 'skill_chip_talent': + return ['minimum', 'maximum']; + case 'os_protocol_talent': + case 'specialization_tree_rank': + return ['base']; + } +} + +function sourceFor( + owner: MechanicAuthoredOwner, + text = 'Mark the target.', +): MechanicAuthoredSource { + return { + version: 1, + owner, + template: text, + variants: variantKeys(owner).map((key) => ({ + key, + materialized: text, + slots: [], + })), + ...(owner.type === 'specialization_tree_rank' + ? { + specialization: { + rankDescription: 'Applies a mark.', + effectId: 'specialization-mark', + effectTemplate: 'Mark the target.', + }, + } + : {}), + }; +} + +function draftFor( + owner: MechanicAuthoredOwner, + simulationReadiness: ConditionalMechanicDraftV1['simulationReadiness'] = 'ready', +): ConditionalMechanicDraftV1 { + return { + version: 1, + simulationReadiness, + scenarioInputs: + simulationReadiness === 'requires_assumptions' + ? [ + { + key: 'nearby-enemies', + label: 'Nearby enemies', + kind: 'number', + readiness: 'requires_assumption', + min: 1, + max: 4, + }, + ] + : [], + pools: [], + clauses: [ + { + key: 'mark-target', + label: 'Mark target', + event: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + key: 'mark', + label: 'Mark', + phase: 'on_event', + kind: 'marker', + scope: 'target', + basis: 'flat', + variants: Object.fromEntries( + variantKeys(owner).map((key) => [key, { value: 1 }]), + ), + }, + ], + }, + ], + }; +} + +async function saveSelected( + owner: MechanicAuthoredOwner, + options: { + text?: string; + simulationReadiness?: ConditionalMechanicDraftV1['simulationReadiness']; + } = {}, +) { + const source = await saveMechanicAuthoredSourceInTransaction( + database, + sourceFor(owner, options.text), + { expectedSourceFingerprint: null }, + ); + const saved = await saveMechanicModelRevisionInTransaction( + database, + source, + draftFor(owner, options.simulationReadiness), + { programName: 'Target mark', origin: 'test', actorId: null }, + ); + return { source, revision: saved.revision }; +} + +const execute = async (sql: string, ...parameters: unknown[]) => + (await database.prepare(sql)).run(...parameters); + +function databaseThatMutatesAfterFirstSelectedRevisionRead( + mutate: () => Promise, +) { + let hasMutated = false; + const reader = new Proxy(database, { + get(target, property, receiver) { + if (property !== 'prepare') { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + return async (sql: string) => { + const statement = await target.prepare(sql); + if (!sql.includes('FROM mechanic_model_selections selection')) { + return statement; + } + return new Proxy(statement, { + get(statementTarget, statementProperty, statementReceiver) { + if (statementProperty !== 'get' && statementProperty !== 'all') { + return Reflect.get( + statementTarget, + statementProperty, + statementReceiver, + ); + } + const read = Reflect.get(statementTarget, statementProperty); + return async (...parameters: unknown[]) => { + const result = await Reflect.apply( + read, + statementTarget, + parameters, + ); + if (!hasMutated) { + hasMutated = true; + await mutate(); + } + return result; + }; + }, + }); + }; + }, + }) as unknown as Database; + return { reader, hasMutated: () => hasMutated }; +} + +function databaseThatMutatesBeforeSecondSnapshotSourceRead( + mutate: () => Promise, +) { + let sourceReadCount = 0; + let hasMutated = false; + const reader = new Proxy(database, { + get(target, property, receiver) { + if (property !== 'prepare') { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + return async (sql: string) => { + const statement = await target.prepare(sql); + if (!sql.includes('FROM mechanic_authored_sources source')) { + return statement; + } + return new Proxy(statement, { + get(statementTarget, statementProperty, statementReceiver) { + if (statementProperty !== 'all') { + return Reflect.get( + statementTarget, + statementProperty, + statementReceiver, + ); + } + const all = Reflect.get(statementTarget, statementProperty); + return async (...parameters: unknown[]) => { + sourceReadCount += 1; + if (sourceReadCount === 2 && !hasMutated) { + hasMutated = true; + await mutate(); + } + return Reflect.apply(all, statementTarget, parameters); + }; + }, + }); + }; + }, + }) as unknown as Database; + return { reader, hasMutated: () => hasMutated }; +} + +function databaseThatCountsQueries() { + let queryCount = 0; + const reader = new Proxy(database, { + get(target, property, receiver) { + if (property !== 'prepare') { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + } + return async (...parameters: unknown[]) => { + queryCount += 1; + return Reflect.apply(target.prepare, target, parameters); + }; + }, + }) as unknown as Database; + return { reader, queryCount: () => queryCount }; +} + +describe('selected mechanic runtime reader', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + }); + + afterEach(async () => { + await database.close(); + }); + + it('reads validated selected revisions for every typed authored owner shape', async () => { + const owners: MechanicAuthoredOwner[] = [ + { type: 'talent', id: 'talent-1', scope: 'standard' }, + { type: 'os_protocol_talent', id: 'protocol-1' }, + { type: 'skill_chip_talent', id: 'chip-1' }, + { type: 'specialization_tree_rank', id: 'rank-1' }, + ]; + + for (const owner of owners) await saveSelected(owner); + + const runtimeIds: string[] = []; + for (const owner of owners) { + const selection = await loadSelectedMechanicRuntime(database, owner); + expect(selection).toMatchObject({ + owner, + simulationReadiness: 'ready', + scenarioInputs: [], + variants: variantKeys(owner).map((key) => ({ key })), + }); + const selectionRuntimeIds = selection?.variants.map( + (variant) => variant.program.id, + ); + expect(selectionRuntimeIds).toEqual( + selection?.variants.map((variant) => + mechanicRuntimeProgramId(selection.sourceId, variant.key), + ), + ); + runtimeIds.push(...(selectionRuntimeIds ?? [])); + } + expect(new Set(runtimeIds).size).toBe(runtimeIds.length); + }); + + it('returns null when no revision has been explicitly selected', async () => { + const owner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-no-selection', + }; + await saveMechanicAuthoredSourceInTransaction(database, sourceFor(owner), { + expectedSourceFingerprint: null, + }); + + await expect( + loadSelectedMechanicRuntime(database, owner), + ).resolves.toBeNull(); + }); + + it('fails closed when the selected revision is stale or the stored source fingerprint is tampered', async () => { + const staleOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-stale', + }; + const stale = await saveSelected(staleOwner); + await saveMechanicAuthoredSourceInTransaction( + database, + sourceFor(staleOwner, 'Mark a different target.'), + { expectedSourceFingerprint: stale.source.fingerprint }, + ); + + await expect( + loadSelectedMechanicRuntime(database, staleOwner), + ).resolves.toBeNull(); + + const tamperedOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-tampered-source', + }; + const tampered = await saveSelected(tamperedOwner); + await execute( + 'UPDATE mechanic_authored_sources SET source_fingerprint=? WHERE id=?', + 'b'.repeat(64), + tampered.source.id, + ); + + await expect( + loadSelectedMechanicRuntime(database, tamperedOwner), + ).resolves.toBeNull(); + }); + + it('rechecks the current source after its selected revision is read', async () => { + const owner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-changes-during-read', + }; + const saved = await saveSelected(owner); + const intercepted = databaseThatMutatesAfterFirstSelectedRevisionRead(() => + saveMechanicAuthoredSourceInTransaction( + database, + sourceFor(owner, 'Mark a newly authored target.'), + { expectedSourceFingerprint: saved.source.fingerprint }, + ).then(() => undefined), + ); + + await expect( + loadSelectedMechanicRuntime(intercepted.reader, owner), + ).resolves.toBeNull(); + expect(intercepted.hasMutated()).toBe(true); + await expect( + loadSelectedMechanicRuntime(database, owner), + ).resolves.toBeNull(); + }); + + it('rejects an in-place revision graph mutation after lifecycle validation', async () => { + const owner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-revision-changes-after-validation', + }; + const saved = await saveSelected(owner); + const intercepted = databaseThatMutatesBeforeSecondSnapshotSourceRead(() => + execute( + 'UPDATE mechanic_model_revision_outcomes SET value=? WHERE revision_id=?', + 2, + saved.revision.id, + ).then(() => undefined), + ); + + await expect( + loadSelectedMechanicRuntime(intercepted.reader, owner), + ).resolves.toBeNull(); + expect(intercepted.hasMutated()).toBe(true); + }); + + it('fails closed for malformed graphs, unsupported contracts, and invalid hashes', async () => { + const malformedOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-malformed', + }; + const malformed = await saveSelected(malformedOwner); + await execute( + 'UPDATE mechanic_model_revision_clauses SET reducer_json=? WHERE revision_id=?', + '{"kind":"unsupported"}', + malformed.revision.id, + ); + await expect( + loadSelectedMechanicRuntime(database, malformedOwner), + ).resolves.toBeNull(); + + const unsupportedOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-unsupported-contract', + }; + const unsupported = await saveSelected(unsupportedOwner); + await execute( + 'UPDATE mechanic_model_revisions SET mechanic_contract_version=? WHERE id=?', + 2, + unsupported.revision.id, + ); + await expect( + loadSelectedMechanicRuntime(database, unsupportedOwner), + ).resolves.toBeNull(); + + const invalidHashOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-invalid-hash', + }; + const invalidHash = await saveSelected(invalidHashOwner); + await execute( + 'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?', + 'b'.repeat(64), + invalidHash.revision.id, + ); + await expect( + loadSelectedMechanicRuntime(database, invalidHashOwner), + ).resolves.toBeNull(); + }); + + it('rejects empty persisted IDs and non-integer revision metadata', async () => { + const emptyIdOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-empty-runtime-ids', + }; + const emptyIds = await saveSelected(emptyIdOwner); + + await database.exec('PRAGMA foreign_keys=OFF'); + try { + await execute( + 'UPDATE mechanic_authored_sources SET id=? WHERE id=?', + '', + emptyIds.source.id, + ); + await execute( + 'UPDATE mechanic_authored_variants SET source_id=? WHERE source_id=?', + '', + emptyIds.source.id, + ); + await execute( + 'UPDATE mechanic_authored_slots SET source_id=? WHERE source_id=?', + '', + emptyIds.source.id, + ); + await execute( + 'UPDATE mechanic_model_revisions SET source_id=? WHERE source_id=?', + '', + emptyIds.source.id, + ); + await execute( + 'UPDATE mechanic_model_revisions SET id=? WHERE id=?', + '', + emptyIds.revision.id, + ); + await execute( + 'UPDATE mechanic_model_selections SET source_id=?,revision_id=? WHERE source_id=?', + '', + '', + emptyIds.source.id, + ); + for (const table of [ + 'mechanic_model_revision_pools', + 'mechanic_model_revision_clauses', + 'mechanic_model_revision_outcomes', + ]) { + await execute( + `UPDATE ${table} SET revision_id=? WHERE revision_id=?`, + '', + emptyIds.revision.id, + ); + } + } finally { + await database.exec('PRAGMA foreign_keys=ON'); + } + + await expect( + loadSelectedMechanicRuntime(database, emptyIdOwner), + ).resolves.toBeNull(); + + const fractionalOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-fractional-revision', + }; + const fractional = await saveSelected(fractionalOwner); + await execute( + 'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?', + 1.5, + fractional.revision.id, + ); + await expect( + loadSelectedMechanicRuntime(database, fractionalOwner), + ).resolves.toBeNull(); + + const nonNumericOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-nonnumeric-revision', + }; + const nonNumeric = await saveSelected(nonNumericOwner); + await execute( + 'UPDATE mechanic_model_revisions SET revision_number=? WHERE id=?', + 'not-a-number', + nonNumeric.revision.id, + ); + await expect( + loadSelectedMechanicRuntime(database, nonNumericOwner), + ).resolves.toBeNull(); + }); + + it('excludes captured-only models and preserves requires-assumptions inputs', async () => { + const capturedOwner: MechanicAuthoredOwner = { + type: 'skill_chip_talent', + id: 'chip-captured', + }; + await saveSelected(capturedOwner, { simulationReadiness: 'captured_only' }); + await expect( + loadSelectedMechanicRuntime(database, capturedOwner), + ).resolves.toBeNull(); + + const assumedOwner: MechanicAuthoredOwner = { + type: 'skill_chip_talent', + id: 'chip-assumed', + }; + await saveSelected(assumedOwner, { + simulationReadiness: 'requires_assumptions', + }); + await expect( + loadSelectedMechanicRuntime(database, assumedOwner), + ).resolves.toMatchObject({ + simulationReadiness: 'requires_assumptions', + scenarioInputs: [ + { + key: 'nearby-enemies', + readiness: 'requires_assumption', + min: 1, + max: 4, + }, + ], + }); + }); + + it('keeps bulk runtime reads bounded at realistic owner counts', async () => { + const owners: MechanicAuthoredOwner[] = Array.from( + { length: 24 }, + (_, index) => { + switch (index % 4) { + case 0: + return { + type: 'talent' as const, + id: `talent-bulk-${index}`, + scope: 'standard' as const, + }; + case 1: + return { + type: 'os_protocol_talent' as const, + id: `protocol-bulk-${index}`, + }; + case 2: + return { + type: 'skill_chip_talent' as const, + id: `chip-bulk-${index}`, + }; + default: + return { + type: 'specialization_tree_rank' as const, + id: `rank-bulk-${index}`, + }; + } + }, + ); + for (const owner of owners) await saveSelected(owner); + const counted = databaseThatCountsQueries(); + + const selections = await loadSelectedMechanicRuntimes( + counted.reader, + owners, + ); + + expect( + [...selections.values()].filter((selection) => selection !== null), + ).toHaveLength(owners.length); + expect(counted.queryCount()).toBeLessThanOrEqual(16); + }); + + it('isolates corrupt owners in multi-owner reads and produces unique runtime IDs', async () => { + const healthyOwner: MechanicAuthoredOwner = { + type: 'talent', + id: 'talent-healthy', + scope: 'standard', + }; + const corruptOwner: MechanicAuthoredOwner = { + type: 'os_protocol_talent', + id: 'protocol-corrupt', + }; + const healthy = await saveSelected(healthyOwner); + const corrupt = await saveSelected(corruptOwner); + await execute( + 'UPDATE mechanic_model_revisions SET program_hash=? WHERE id=?', + 'b'.repeat(64), + corrupt.revision.id, + ); + + const selections = await loadSelectedMechanicRuntimes(database, [ + healthyOwner, + corruptOwner, + ]); + const healthySelection = selections.get( + mechanicRuntimeOwnerKey(healthyOwner), + ); + expect(healthySelection).toMatchObject({ sourceId: healthy.source.id }); + expect(selections.get(mechanicRuntimeOwnerKey(corruptOwner))).toBeNull(); + + const runtimeIds = healthySelection?.variants.map( + (variant) => variant.program.id, + ); + expect(runtimeIds).toHaveLength(2); + expect(new Set(runtimeIds).size).toBe(2); + expect(runtimeIds).toEqual([ + mechanicRuntimeProgramId(healthy.source.id, 'I'), + mechanicRuntimeProgramId(healthy.source.id, 'II'), + ]); + }); +}); diff --git a/src/lib/server/mechanic-runtime-selection.ts b/src/lib/server/mechanic-runtime-selection.ts new file mode 100644 index 0000000..17358f8 --- /dev/null +++ b/src/lib/server/mechanic-runtime-selection.ts @@ -0,0 +1,735 @@ +import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description'; +import { + classifyMechanicVariantLifecycle, + hashMechanicAuthoredSource, + type MechanicSimulationReadiness, +} from '$lib/calculations/mechanic-lifecycle'; +import type { + MechanicProgram, + ScenarioInputDefinition, +} from '$lib/calculations/mechanic'; +import type { MechanicAttributeLabelResolver } from '$lib/calculations/mechanic-public-renderer'; +import type { Database } from '@tursodatabase/database'; +import { + loadMechanicAuthoredSource, + type MechanicAuthoredSourceRecord, +} from './mechanic-authored-sources'; +import { + loadSelectedMechanicModelRevision, + type MechanicModelRevisionRecord, +} from './mechanic-model-revisions'; + +export type MechanicRuntimeOwnerKey = string; +export type MechanicRuntimeOwnerReference = Pick< + MechanicAuthoredOwner, + 'type' | 'id' +>; +export type MechanicRuntimeOwnerReferenceKey = string; +export type MechanicRuntimeSimulationReadiness = Exclude< + MechanicSimulationReadiness, + 'captured_only' +>; + +/** A validated selected program, ordered by its current authored variants. */ +export type MechanicRuntimeVariant = { + key: string; + label: string; + program: MechanicProgram; +}; + +/** + * The executable portion of one explicitly selected, current mechanic model. + * Captured-only models intentionally never reach this boundary. + */ +export type MechanicRuntimeSelection = { + owner: MechanicAuthoredOwner; + sourceId: string; + sourceFingerprint: string; + revisionId: string; + revisionNumber: number; + simulationReadiness: MechanicRuntimeSimulationReadiness; + scenarioInputs: ScenarioInputDefinition[]; + variants: MechanicRuntimeVariant[]; +}; + +/** + * Produces an unambiguous stable key for a typed owner reference. Talent + * metadata is included because it is part of that owner's authored contract. + */ +export function mechanicRuntimeOwnerKey( + owner: MechanicAuthoredOwner, +): MechanicRuntimeOwnerKey { + return JSON.stringify( + owner.type === 'talent' + ? [owner.type, owner.id, owner.scope, Boolean(owner.hasTierII)] + : [owner.type, owner.id], + ); +} + +/** Produces a stable key before the source row supplies talent metadata. */ +export function mechanicRuntimeOwnerReferenceKey( + owner: MechanicRuntimeOwnerReference, +): MechanicRuntimeOwnerReferenceKey { + return JSON.stringify([owner.type, owner.id]); +} + +/** The generated ID is deliberately outside the stored canonical hash. */ +export function mechanicRuntimeProgramId(sourceId: string, variantKey: string) { + return `mechanic-runtime:${JSON.stringify([sourceId, variantKey])}`; +} + +function ownersMatch( + expected: MechanicAuthoredOwner, + actual: MechanicAuthoredOwner, +) { + if (expected.type !== actual.type || expected.id !== actual.id) return false; + return ( + expected.type !== 'talent' || + (actual.type === 'talent' && + expected.scope === actual.scope && + Boolean(expected.hasTierII) === Boolean(actual.hasTierII)) + ); +} + +function hasCurrentVariantOrder( + ownerVariants: readonly { key: string }[], + selectedVariants: readonly { key: string }[], +) { + return ( + ownerVariants.length === selectedVariants.length && + ownerVariants.every( + (variant, index) => variant.key === selectedVariants[index]?.key, + ) + ); +} + +async function loadAttributeLabelResolver( + database: Database, +): Promise { + const rows = await ( + await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1') + ).all(); + const labels = new Map(); + for (const row of rows) { + if ( + typeof row.id === 'string' && + row.id.trim() && + typeof row.name === 'string' && + row.name.trim() + ) { + labels.set(row.id, row.name); + } + } + return (attributeId) => labels.get(attributeId) ?? null; +} + +type RuntimeSnapshotRow = Record; +type RuntimeSnapshot = { + sourceByOwner: Map; + variantsBySourceId: Map; + slotsBySourceId: Map; + selectionBySourceId: Map; + selectedRevisionBySourceId: Map; + poolsByRevisionId: Map; + clausesByRevisionId: Map; + outcomesByRevisionId: Map; + activeAttributes: RuntimeSnapshotRow[]; +}; + +type RuntimeSnapshotCandidate = { + selection: MechanicRuntimeSelection; + token: string; +}; + +const MAX_STABLE_READ_ATTEMPTS = 2; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function hasValidRuntimeIdentity( + source: MechanicAuthoredSourceRecord, + selected: MechanicModelRevisionRecord, +) { + return ( + isNonEmptyString(source.id) && + isNonEmptyString(source.fingerprint) && + isNonEmptyString(selected.id) && + isNonEmptyString(selected.sourceId) && + Number.isFinite(selected.revisionNumber) && + Number.isInteger(selected.revisionNumber) && + selected.revisionNumber > 0 + ); +} + +function runtimeSnapshotOwnerKey(type: unknown, id: unknown) { + return `${String(type)}:${String(id)}`; +} + +function runtimeOwnerFromSnapshotRow( + row: RuntimeSnapshotRow, +): MechanicAuthoredOwner | null { + if (!isNonEmptyString(row.owner_id)) return null; + switch (row.owner_type) { + case 'talent': + if (row.talent_scope !== 'standard' && row.talent_scope !== 'exotic') { + return null; + } + return { + type: 'talent', + id: row.owner_id, + scope: row.talent_scope, + ...(Number(row.talent_has_tier_ii ?? 0) === 1 + ? { hasTierII: true } + : {}), + }; + case 'os_protocol_talent': + return { type: 'os_protocol_talent', id: row.owner_id }; + case 'skill_chip_talent': + return { type: 'skill_chip_talent', id: row.owner_id }; + case 'specialization_tree_rank': + return { type: 'specialization_tree_rank', id: row.owner_id }; + default: + return null; + } +} + +function mapFirstRowByStringColumn( + rows: readonly RuntimeSnapshotRow[], + column: string, +) { + const result = new Map(); + for (const row of rows) { + const value = row[column]; + if (typeof value === 'string' && !result.has(value)) { + result.set(value, row); + } + } + return result; +} + +function mapRowsByStringColumn( + rows: readonly RuntimeSnapshotRow[], + column: string, +) { + const result = new Map(); + for (const row of rows) { + const value = row[column]; + if (typeof value !== 'string') continue; + result.set(value, [...(result.get(value) ?? []), row]); + } + return result; +} + +function copiedRow(row: RuntimeSnapshotRow | undefined) { + return row === undefined ? undefined : { ...row }; +} + +function copiedRows(rows: RuntimeSnapshotRow[] | undefined) { + return (rows ?? []).map((row) => ({ ...row })); +} + +async function loadRuntimeSnapshotRows( + database: Database, + sql: string, + ids: readonly string[], +): Promise { + return (await ( + await database.prepare(sql) + ).all(JSON.stringify(ids))) as RuntimeSnapshotRow[]; +} + +/** + * Reads every row the runtime validator needs in a bounded number of queries. + * These rows remain private input to the existing source and revision readers. + */ +async function loadRuntimeSnapshot( + database: Database, + owners: readonly MechanicRuntimeOwnerReference[], +): Promise { + const requestedOwners = [ + ...new Map( + owners + .filter((owner) => isNonEmptyString(owner.id)) + .map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]), + ).values(), + ]; + const sourceRows = (await ( + await database.prepare(`SELECT source.* + FROM mechanic_authored_sources source + JOIN json_each(?) requested + ON source.owner_type=json_extract(requested.value,'$.type') + AND source.owner_id=json_extract(requested.value,'$.id')`) + ).all(JSON.stringify(requestedOwners))) as RuntimeSnapshotRow[]; + const sourceByOwner = new Map(); + for (const source of sourceRows) { + if ( + typeof source.owner_type === 'string' && + typeof source.owner_id === 'string' + ) { + sourceByOwner.set( + runtimeSnapshotOwnerKey(source.owner_type, source.owner_id), + source, + ); + } + } + const sourceById = mapFirstRowByStringColumn(sourceRows, 'id'); + const sourceIds = [...sourceById.keys()]; + if (!sourceIds.length) { + return { + sourceByOwner, + variantsBySourceId: new Map(), + slotsBySourceId: new Map(), + selectionBySourceId: new Map(), + selectedRevisionBySourceId: new Map(), + poolsByRevisionId: new Map(), + clausesByRevisionId: new Map(), + outcomesByRevisionId: new Map(), + activeAttributes: [], + }; + } + + const [variantRows, slotRows, revisionRows, activeAttributes] = await Promise.all([ + loadRuntimeSnapshotRows( + database, + `SELECT * FROM mechanic_authored_variants + WHERE source_id IN (SELECT value FROM json_each(?)) + ORDER BY source_id,sort_order`, + sourceIds, + ), + loadRuntimeSnapshotRows( + database, + `SELECT * FROM mechanic_authored_slots + WHERE source_id IN (SELECT value FROM json_each(?)) + ORDER BY source_id,variant_key,sort_order`, + sourceIds, + ), + loadRuntimeSnapshotRows( + database, + `SELECT + selection.source_id AS selection_source_id, + selection.revision_id AS selection_revision_id, + selection.updated_at AS selection_updated_at, + revision.* + FROM mechanic_model_selections selection + JOIN mechanic_model_revisions revision + ON revision.id=selection.revision_id + AND revision.source_id=selection.source_id + WHERE selection.source_id IN (SELECT value FROM json_each(?))`, + sourceIds, + ), + (async () => + (await ( + await database.prepare( + 'SELECT id,name FROM stat_attributes WHERE active=1', + ) + ).all()) as RuntimeSnapshotRow[])(), + ]); + const revisionIds = [ + ...new Set( + revisionRows.flatMap((revision) => + typeof revision.id === 'string' ? [revision.id] : [], + ), + ), + ]; + const [poolRows, clauseRows, outcomeRows] = revisionIds.length + ? await Promise.all([ + loadRuntimeSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_pools + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order`, + revisionIds, + ), + loadRuntimeSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_clauses + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order`, + revisionIds, + ), + loadRuntimeSnapshotRows( + database, + `SELECT * FROM mechanic_model_revision_outcomes + WHERE revision_id IN (SELECT value FROM json_each(?)) + ORDER BY revision_id,sort_order,variant_key`, + revisionIds, + ), + ]) + : [[], [], []]; + + return { + sourceByOwner, + variantsBySourceId: mapRowsByStringColumn(variantRows, 'source_id'), + slotsBySourceId: mapRowsByStringColumn(slotRows, 'source_id'), + selectionBySourceId: mapFirstRowByStringColumn( + revisionRows, + 'selection_source_id', + ), + selectedRevisionBySourceId: mapFirstRowByStringColumn( + revisionRows, + 'source_id', + ), + poolsByRevisionId: mapRowsByStringColumn(poolRows, 'revision_id'), + clausesByRevisionId: mapRowsByStringColumn(clauseRows, 'revision_id'), + outcomesByRevisionId: mapRowsByStringColumn(outcomeRows, 'revision_id'), + activeAttributes, + }; +} + +/** + * Replays the exact source, selected-revision, and active-attribute queries + * against a private bulk snapshot. This retains the established single-owner + * validation and parser behavior without per-owner database fan-out. + */ +function runtimeSnapshotDatabase(snapshot: RuntimeSnapshot): Database { + return { + prepare: async (sql: string) => { + const normalizedSql = sql.replace(/\s+/g, ' ').trim(); + const sourceId = (parameters: unknown[]) => + typeof parameters[0] === 'string' ? parameters[0] : ''; + const sourceForOwner = (parameters: unknown[]) => { + const [ownerType, ownerId] = parameters; + return typeof ownerType === 'string' && typeof ownerId === 'string' + ? snapshot.sourceByOwner.get(runtimeSnapshotOwnerKey(ownerType, ownerId)) + : undefined; + }; + const selectedRevision = (parameters: unknown[]) => + snapshot.selectedRevisionBySourceId.get(sourceId(parameters)); + + return { + get: async (...parameters: unknown[]) => { + if ( + normalizedSql === + 'SELECT * FROM mechanic_authored_sources WHERE owner_type=? AND owner_id=?' + ) { + return copiedRow(sourceForOwner(parameters)); + } + if ( + normalizedSql.startsWith( + 'SELECT revision.* FROM mechanic_model_selections selection JOIN mechanic_model_revisions revision ', + ) + ) { + return copiedRow(selectedRevision(parameters)); + } + throw new Error( + `Unsupported mechanic runtime snapshot query: ${normalizedSql}`, + ); + }, + all: async (...parameters: unknown[]) => { + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_authored_variants ', + ) + ) { + return copiedRows( + snapshot.variantsBySourceId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith('SELECT * FROM mechanic_authored_slots ') + ) { + return copiedRows(snapshot.slotsBySourceId.get(sourceId(parameters))); + } + if ( + normalizedSql === + 'SELECT id,name FROM stat_attributes WHERE active=1' + ) { + return copiedRows(snapshot.activeAttributes); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_pools ', + ) + ) { + return copiedRows( + snapshot.poolsByRevisionId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_clauses ', + ) + ) { + return copiedRows( + snapshot.clausesByRevisionId.get(sourceId(parameters)), + ); + } + if ( + normalizedSql.startsWith( + 'SELECT * FROM mechanic_model_revision_outcomes ', + ) + ) { + return copiedRows( + snapshot.outcomesByRevisionId.get(sourceId(parameters)), + ); + } + throw new Error( + `Unsupported mechanic runtime snapshot query: ${normalizedSql}`, + ); + }, + }; + }, + } as unknown as Database; +} + +async function loadVerifiedAuthoredSource( + database: Database, + owner: MechanicAuthoredOwner, +): Promise<{ + source: MechanicAuthoredSourceRecord; + fingerprint: string; +} | null> { + const source = await loadMechanicAuthoredSource( + database, + owner.type, + owner.id, + ); + if (!source || !ownersMatch(owner, source.source.owner)) return null; + + const fingerprint = await hashMechanicAuthoredSource(source.source); + if (!fingerprint.ok || fingerprint.fingerprint !== source.fingerprint) { + return null; + } + return { source, fingerprint: fingerprint.fingerprint }; +} + +async function loadRuntimeSelection( + database: Database, + owner: MechanicAuthoredOwner, +): Promise { + const current = await loadVerifiedAuthoredSource(database, owner); + if (!current) return null; + + // This reader intentionally never falls back to a latest or historical row. + const selected = await loadSelectedMechanicModelRevision( + database, + current.source.id, + ); + if ( + !selected || + !hasValidRuntimeIdentity(current.source, selected) || + selected.sourceId !== current.source.id || + !hasCurrentVariantOrder(current.source.source.variants, selected.variants) + ) { + return null; + } + + const lifecycle = await classifyMechanicVariantLifecycle({ + currentSourceFingerprint: current.fingerprint, + resolveAttributeLabel: await loadAttributeLabelResolver(database), + selectedRevision: { + sourceFingerprint: selected.sourceFingerprint, + mechanicContractVersion: selected.mechanicContractVersion, + programHash: selected.programHash, + simulationReadiness: selected.simulationReadiness, + variants: selected.variants, + }, + }); + if (lifecycle.status !== 'current') return null; + if (lifecycle.simulationReadiness === 'captured_only') return null; + + return { + owner: current.source.source.owner, + sourceId: current.source.id, + sourceFingerprint: lifecycle.fingerprint, + revisionId: selected.id, + revisionNumber: selected.revisionNumber, + simulationReadiness: lifecycle.simulationReadiness, + scenarioInputs: selected.draft.scenarioInputs, + variants: selected.variants.map((variant) => ({ + key: variant.key, + label: variant.label ?? variant.key, + // Stored programs are validated and hash-checked before this runtime-only + // generated identity is assigned. + program: { + ...variant.program, + id: mechanicRuntimeProgramId(current.source.id, variant.key), + }, + })), + }; +} + +/** + * Includes every executable input for one owner. A selection ID check alone is + * insufficient because revision rows are conventionally append-only rather + * than database-enforced immutable. + */ +function runtimeSnapshotToken( + snapshot: RuntimeSnapshot, + reference: MechanicRuntimeOwnerReference, + selection: MechanicRuntimeSelection, +): string | null { + const source = snapshot.sourceByOwner.get( + runtimeSnapshotOwnerKey(reference.type, reference.id), + ); + if (!source || source.id !== selection.sourceId) return null; + const selected = snapshot.selectionBySourceId.get(selection.sourceId); + const revision = snapshot.selectedRevisionBySourceId.get(selection.sourceId); + if ( + !selected || + !revision || + selected.selection_revision_id !== selection.revisionId || + revision.id !== selection.revisionId || + revision.source_id !== selection.sourceId + ) { + return null; + } + return JSON.stringify({ + source, + variants: snapshot.variantsBySourceId.get(selection.sourceId) ?? [], + slots: snapshot.slotsBySourceId.get(selection.sourceId) ?? [], + selection: selected, + revision, + pools: snapshot.poolsByRevisionId.get(selection.revisionId) ?? [], + clauses: snapshot.clausesByRevisionId.get(selection.revisionId) ?? [], + outcomes: snapshot.outcomesByRevisionId.get(selection.revisionId) ?? [], + attributes: snapshot.activeAttributes, + }); +} + +async function runtimeSnapshotCandidate( + snapshot: RuntimeSnapshot, + reference: MechanicRuntimeOwnerReference, +): Promise { + const sourceRow = snapshot.sourceByOwner.get( + runtimeSnapshotOwnerKey(reference.type, reference.id), + ); + if (!sourceRow) return null; + const owner = runtimeOwnerFromSnapshotRow(sourceRow); + if (!owner) return null; + + try { + const selection = await loadRuntimeSelection( + runtimeSnapshotDatabase(snapshot), + owner, + ); + if (!selection) return null; + const token = runtimeSnapshotToken(snapshot, reference, selection); + return token ? { selection, token } : null; + } catch { + return null; + } +} + +function uniqueRuntimeOwnerReferences( + owners: readonly MechanicRuntimeOwnerReference[], +) { + return [ + ...new Map( + owners + .filter((owner) => isNonEmptyString(owner.id)) + .map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), owner]), + ).values(), + ]; +} + +async function loadStableRuntimeSelections( + database: Database, + owners: readonly MechanicRuntimeOwnerReference[], +) { + const result = new Map< + MechanicRuntimeOwnerReferenceKey, + MechanicRuntimeSelection | null + >(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null])); + let pendingOwners = [...owners]; + + for (let attempt = 0; attempt < MAX_STABLE_READ_ATTEMPTS; attempt += 1) { + const initial = await loadRuntimeSnapshot(database, pendingOwners); + const candidates = await Promise.all( + pendingOwners.map((owner) => runtimeSnapshotCandidate(initial, owner)), + ); + if (!candidates.some((candidate) => candidate !== null)) break; + + const reloaded = await loadRuntimeSnapshot(database, pendingOwners); + const unstableOwners: MechanicRuntimeOwnerReference[] = []; + + for (const [index, owner] of pendingOwners.entries()) { + const candidate = candidates[index]; + if (!candidate) continue; + const reloadedToken = runtimeSnapshotToken( + reloaded, + owner, + candidate.selection, + ); + if (candidate.token === reloadedToken) { + result.set(mechanicRuntimeOwnerReferenceKey(owner), candidate.selection); + } else { + unstableOwners.push(owner); + } + } + pendingOwners = unstableOwners; + if (!pendingOwners.length) break; + } + + return result; +} + +/** + * Bulk-hydrates selected runtimes when caller metadata has only an owner type + * and ID. The current source supplies talent scope and tier metadata, which is + * validated again while assembling every executable selection. + */ +export async function loadSelectedMechanicRuntimesForOwnerReferences( + database: Database, + owners: readonly MechanicRuntimeOwnerReference[], +): Promise< + Map +> { + const references = uniqueRuntimeOwnerReferences(owners); + const selections = new Map< + MechanicRuntimeOwnerReferenceKey, + MechanicRuntimeSelection | null + >(owners.map((owner) => [mechanicRuntimeOwnerReferenceKey(owner), null])); + if (!references.length) return selections; + + try { + const stable = await loadStableRuntimeSelections(database, references); + for (const reference of references) { + selections.set( + mechanicRuntimeOwnerReferenceKey(reference), + stable.get(mechanicRuntimeOwnerReferenceKey(reference)) ?? null, + ); + } + } catch { + // A failed bulk read has no executable fallback at this boundary. + } + + return selections; +} + +/** + * Loads only the explicitly selected revision for one typed owner. Any stale, + * unsupported, malformed, or captured-only record is unavailable to runtime. + */ +export async function loadSelectedMechanicRuntime( + database: Database, + owner: MechanicAuthoredOwner, +): Promise { + const selections = await loadSelectedMechanicRuntimes(database, [owner]); + return selections.get(mechanicRuntimeOwnerKey(owner)) ?? null; +} + +/** + * Resolves each owner independently. A corrupt source remains null without + * suppressing healthy owners in the shared bounded runtime snapshot. + */ +export async function loadSelectedMechanicRuntimes( + database: Database, + owners: readonly MechanicAuthoredOwner[], +): Promise> { + const byReference = await loadSelectedMechanicRuntimesForOwnerReferences( + database, + owners, + ); + return new Map( + owners.map((owner) => { + const selection = + byReference.get(mechanicRuntimeOwnerReferenceKey(owner)) ?? null; + return [ + mechanicRuntimeOwnerKey(owner), + selection && ownersMatch(owner, selection.owner) ? selection : null, + ]; + }), + ); +} diff --git a/src/lib/server/shared-build-summary.test.ts b/src/lib/server/shared-build-summary.test.ts new file mode 100644 index 0000000..d5b067a --- /dev/null +++ b/src/lib/server/shared-build-summary.test.ts @@ -0,0 +1,306 @@ +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BuildMechanicRuntimes } from './build-mechanic-runtime'; +import type { MechanicRuntimeSelection } from './mechanic-runtime-selection'; + +type HydratedCombatEffect = { + id: string; + definition: { id: string }; + runtime: MechanicRuntimeSelection | null; +}; + +const mocks = vi.hoisted(() => ({ + loadRuntimes: vi.fn(), +})); + +vi.mock('./build-mechanic-runtime', () => ({ + loadBuildMechanicRuntimes: mocks.loadRuntimes, +})); + +import { loadSharedBuildSummaryCatalog } from './shared-build-summary'; + +const selected: MechanicRuntimeSelection = { + owner: { type: 'talent', id: 'source', scope: 'standard', hasTierII: true }, + sourceId: 'source', + sourceFingerprint: 'a'.repeat(64), + revisionId: 'revision', + revisionNumber: 1, + simulationReadiness: 'requires_assumptions', + scenarioInputs: [ + { + key: 'nearby-enemies', + label: 'Nearby enemies', + kind: 'number', + readiness: 'requires_assumption', + min: 1, + max: 4, + }, + ], + variants: [ + { + key: 'base', + label: 'BASE', + program: { + id: 'mechanic-runtime:["source","base"]', + name: 'Selected', + scenarioInputs: [ + { + key: 'nearby-enemies', + label: 'Nearby enemies', + kind: 'number', + readiness: 'requires_assumption', + min: 1, + max: 4, + }, + ], + clauses: [], + }, + }, + ], +}; + +const runtimes: BuildMechanicRuntimes = { + talents: { 'talent-1': selected }, + protocolTalents: { 'protocol-talent-1': selected }, + skillChipTalents: { 'chip-talent-1': selected }, + specializationAttachments: { + 'attachment-1': selected, + 'attachment-2': selected, + }, +}; + +const applySchema = async (database: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await database.exec(statement); + } +}; + +const seedSpecializationAttachments = async (database: Database) => { + await database.exec(` + INSERT INTO specializations(id,name,created_at,updated_at) VALUES + ('specialization-parent','Parent specialization','now','now'), + ('specialization-cross','Cross specialization','now','now'); + INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES('focus-parent','specialization-parent','Parent focus','Firepower',0,'now','now'); + INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES('node-parent','focus-parent','Parent node','',1,1,1,'now','now'); + INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-parent',1,''); + INSERT INTO specialization_combat_effects( + id,specialization_id,name,description_template,created_at,updated_at + ) VALUES + ('effect-valid-first','specialization-parent','Valid first','', 'now','now'), + ('effect-valid-second','specialization-parent','Valid second','', 'now','now'), + ('effect-cross','specialization-cross','Cross specialization effect','', 'now','now'); + INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES + ('attachment-valid-first','node-parent',1,'effect-valid-first',1, + 'enemy_hits_within_window',1,1,1,1), + ('attachment-valid-second','node-parent',1,'effect-valid-second',2, + 'enemy_hits_within_window',1,1,1,1), + ('attachment-cross','node-parent',1,'effect-cross',3, + 'enemy_hits_within_window',1,1,1,1); + `); +}; + +function catalogDatabase() { + const prepare = vi.fn(async (sql: string) => ({ + all: vi.fn(async () => { + if (sql.includes('FROM talents ')) + return [{ id: 'talent-1', name: 'Talent' }]; + if (sql.includes('FROM skill_chip_sets')) + return [ + { + id: 'chip-set', + name: 'Chip', + specialization_ability_id: 'ability', + }, + ]; + if (sql.includes('FROM skill_chip_talents')) + return [ + { + id: 'chip-talent-1', + skill_chip_set_id: 'chip-set', + name: 'Chip Talent', + }, + ]; + if (sql.includes('FROM os_protocols')) + return [ + { + id: 'protocol-1', + name: 'Protocol', + quality: 'High-end', + primary_attribute_type: 'Health', + }, + ]; + if (sql.includes('FROM os_protocol_talents')) + return [ + { + id: 'protocol-talent-1', + protocol_id: 'protocol-1', + name: 'Protocol Talent', + }, + ]; + if (sql.includes('FROM specializations ')) + return [{ id: 'specialization-1', name: 'Spec' }]; + if (sql.includes('FROM specialization_focuses')) + return [ + { + id: 'focus-1', + specialization_id: 'specialization-1', + display_order: 1, + name: 'Focus', + }, + ]; + if (sql.includes('FROM specialization_tree_nodes')) + return [ + { + id: 'node-1', + focus_id: 'focus-1', + row_number: 1, + column_number: 1, + }, + ]; + if (sql.includes('FROM specialization_tree_node_ranks')) + return [{ node_id: 'node-1', rank: 1 }]; + if (sql.includes('FROM specialization_tree_rank_combat_effects')) + return [ + { + id: 'attachment-1', + node_id: 'node-1', + rank: 1, + combat_effect_id: 'reusable-effect', + effect_order: 1, + definition_name: 'First effect', + }, + { + id: 'attachment-2', + node_id: 'node-1', + rank: 1, + combat_effect_id: 'reusable-effect', + effect_order: 2, + definition_name: 'Second effect', + }, + ]; + return []; + }), + })); + return { prepare } as unknown as Database; +} + +describe('shared Build summary mechanic hydration', () => { + beforeEach(() => { + mocks.loadRuntimes.mockReset().mockResolvedValue(runtimes); + }); + + it('uses the shared selected-runtime capability for all owners and active attachment order', async () => { + const database = catalogDatabase(); + const catalog = await loadSharedBuildSummaryCatalog(database); + + expect(catalog.mechanics).toBe(runtimes); + expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, { + talentIds: ['talent-1'], + protocolTalentIds: ['protocol-talent-1'], + skillChipTalentIds: ['chip-talent-1'], + specializationAttachmentIds: ['attachment-1', 'attachment-2'], + }); + expect(catalog.talents[0]?.runtime).toBe(selected); + expect(catalog.chipSets[0]?.talent?.runtime).toBe(selected); + expect(catalog.protocols[0]?.talent?.runtime).toBe(selected); + const effects = catalog.specializations[0]?.focuses[0]?.nodes[0]?.ranks[0] + ?.combatEffects as + | Array<{ + id: string; + runtime: MechanicRuntimeSelection | null; + definition: { id: string }; + }> + | undefined; + expect(effects?.map((effect) => effect.id)).toEqual([ + 'attachment-1', + 'attachment-2', + ]); + expect(effects?.map((effect) => effect.runtime)).toEqual([ + selected, + selected, + ]); + expect( + effects?.every((effect) => effect.definition.id === 'reusable-effect'), + ).toBe(true); + expect( + (database.prepare as ReturnType).mock.calls.some(([sql]) => + String(sql).includes('ORDER BY specialization.id,focus.display_order'), + ), + ).toBe(true); + }); +}); + +describe('shared Build specialization combat-effect ownership', () => { + let database: Database; + + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + await seedSpecializationAttachments(database); + }); + + afterEach(async () => { + await database.close(); + }); + + it('hydrates only same-specialization effects in attachment order and does not select corrupt attachment runtimes', async () => { + const firstRuntime = { ...selected, sourceId: 'first-runtime' }; + const secondRuntime = { ...selected, sourceId: 'second-runtime' }; + mocks.loadRuntimes.mockReset().mockResolvedValue({ + talents: {}, + protocolTalents: {}, + skillChipTalents: {}, + specializationAttachments: { + 'attachment-valid-first': firstRuntime, + 'attachment-valid-second': secondRuntime, + }, + } satisfies BuildMechanicRuntimes); + + const catalog = await loadSharedBuildSummaryCatalog(database); + + expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, { + talentIds: [], + protocolTalentIds: [], + skillChipTalentIds: [], + specializationAttachmentIds: [ + 'attachment-valid-first', + 'attachment-valid-second', + ], + }); + const parentSpecialization = catalog.specializations.find( + (specialization: { id: string }) => + specialization.id === 'specialization-parent', + ); + const combatEffects = (parentSpecialization?.focuses[0]?.nodes[0]?.ranks[0] + ?.combatEffects ?? []) as HydratedCombatEffect[]; + expect(combatEffects.map((effect) => effect.id)).toEqual([ + 'attachment-valid-first', + 'attachment-valid-second', + ]); + expect(combatEffects.map((effect) => effect.definition.id)).toEqual([ + 'effect-valid-first', + 'effect-valid-second', + ]); + expect(combatEffects.map((effect) => effect.runtime)).toEqual([ + firstRuntime, + secondRuntime, + ]); + }); +}); diff --git a/src/lib/server/shared-build-summary.ts b/src/lib/server/shared-build-summary.ts index 0274d0c..8a8a138 100644 --- a/src/lib/server/shared-build-summary.ts +++ b/src/lib/server/shared-build-summary.ts @@ -1,6 +1,7 @@ import type { Database } from '@tursodatabase/database'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles'; +import { loadBuildMechanicRuntimes } from './build-mechanic-runtime'; export async function loadSharedBuildSummaryCatalog(database: Database) { const [ @@ -37,6 +38,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { specializationNodes, specializationRanks, specializationRankEffects, + specializationRankCombatEffects, ] = await Promise.all([ ( await database.prepare( @@ -172,9 +174,35 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { 'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order', ) ).all(), + ( + await database.prepare(`SELECT attachment.*,effect.name AS definition_name + FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_node_ranks rank + ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specializations specialization + ON specialization.id=focus.specialization_id AND specialization.active=1 + JOIN specialization_combat_effects effect + ON effect.id=attachment.combat_effect_id + AND effect.specialization_id=focus.specialization_id + AND effect.active=1 + ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE, + node.row_number,node.column_number,attachment.rank, + attachment.effect_order,attachment.id`) + ).all(), ]); + const mechanics = await loadBuildMechanicRuntimes(database, { + talentIds: talents.map((talent) => String(talent.id)), + protocolTalentIds: protocolTalents.map((talent) => String(talent.id)), + skillChipTalentIds: chipTalents.map((talent) => String(talent.id)), + specializationAttachmentIds: specializationRankCombatEffects.map((effect) => + String(effect.id), + ), + }); return { + mechanics, attributes, gear, gearAugments, @@ -187,6 +215,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses), talents: talents.map((talent) => ({ ...talent, + runtime: mechanics.talents[String(talent.id)] ?? null, template: talentTemplates.find((item) => item.talent_id === talent.id) ?.template ?? '', @@ -231,6 +260,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { talent: talent ? { ...talent, + runtime: mechanics.skillChipTalents[String(talent.id)] ?? null, effects: chipTalentEffects.filter( (effect) => effect.talent_id === talent.id, ), @@ -260,6 +290,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { talent: talent ? { ...talent, + runtime: mechanics.protocolTalents[String(talent.id)] ?? null, effects: protocolTalentEffects.filter( (effect) => effect.talent_id === talent.id, ), @@ -298,6 +329,23 @@ export async function loadSharedBuildSummaryCatalog(database: Database) { effect.node_id === node.id && Number(effect.rank) === Number(rank.rank), ), + combatEffects: specializationRankCombatEffects + .filter( + (effect) => + effect.node_id === node.id && + Number(effect.rank) === Number(rank.rank), + ) + .map((effect) => ({ + ...effect, + definition: { + id: String(effect.combat_effect_id), + name: String(effect.definition_name), + }, + runtime: + mechanics.specializationAttachments[ + String(effect.id) + ] ?? null, + })), })), })), })), diff --git a/src/lib/server/shared-build.test.ts b/src/lib/server/shared-build.test.ts index 4506f94..651cb69 100644 --- a/src/lib/server/shared-build.test.ts +++ b/src/lib/server/shared-build.test.ts @@ -4,6 +4,7 @@ import { createDraft } from '$lib/builder/loadout'; const mocks = vi.hoisted(() => ({ row: null as Record | null, loadCatalog: vi.fn(), + summarizeLoadout: vi.fn(), })); vi.mock('$lib/server/database', () => ({ @@ -14,12 +15,101 @@ vi.mock('$lib/server/database', () => ({ })), })), })); +vi.mock('$lib/builder/loadout-summary', () => ({ + summarizeLoadout: mocks.summarizeLoadout, +})); vi.mock('$lib/server/shared-build-summary', () => ({ loadSharedBuildSummaryCatalog: mocks.loadCatalog, })); import { loadSharedBuildProjection } from './shared-build'; +const selectedRuntime = { + owner: { type: 'talent', id: 'talent-1', scope: 'standard' }, + sourceId: 'talent-source', + sourceFingerprint: 'f'.repeat(64), + revisionId: 'talent-revision', + revisionNumber: 4, + simulationReadiness: 'ready', + scenarioInputs: [ + { key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' }, + ], + variants: [ + { + key: 'I', + label: 'Tier I', + program: { + id: 'mechanic-runtime:["talent-source","I"]', + name: 'Selected mechanic', + scenarioInputs: [ + { key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' }, + ], + pools: [{ key: 'stacks', label: 'Stacks', min: 0, max: 10 }], + clauses: [ + { + key: 'on-kill', + outcomes: [ + { + key: 'damage', + variants: { I: { value: 15 } }, + }, + ], + }, + ], + }, + }, + ], +}; + +const mechanics = { + talents: { 'talent-1': selectedRuntime }, + protocolTalents: { 'protocol-talent-1': selectedRuntime }, + skillChipTalents: { 'chip-talent-1': selectedRuntime }, + specializationAttachments: { 'attachment-1': selectedRuntime }, +}; + +const mechanicInternalKeys = new Set([ + 'mechanics', + 'runtime', + 'sourceId', + 'sourceFingerprint', + 'revisionId', + 'revisionNumber', + 'simulationReadiness', + 'variants', + 'key', + 'program', + 'scenarioInputs', + 'pools', + 'clauses', + 'outcomes', + 'programHash', + 'mechanicContractVersion', + 'selectedRevisionId', + 'source_id', + 'source_fingerprint', + 'revision_id', + 'revision_number', + 'simulation_readiness', + 'scenario_inputs', + 'program_hash', + 'mechanic_contract_version', + 'selected_revision_id', +]); + +function serializedMechanicInternals(value: unknown, path = '$'): string[] { + if (Array.isArray(value)) + return value.flatMap((entry, index) => + serializedMechanicInternals(entry, `${path}[${index}]`), + ); + if (!value || typeof value !== 'object') return []; + + return Object.entries(value).flatMap(([key, entry]) => [ + ...(mechanicInternalKeys.has(key) ? [`${path}.${key}`] : []), + ...serializedMechanicInternals(entry, `${path}.${key}`), + ]); +} + function buildRow(visibility: 'private' | 'unlisted' | 'public') { return { id: 'build-id', @@ -37,16 +127,101 @@ function buildRow(visibility: 'private' | 'unlisted' | 'public') { describe('shared Build access policy', () => { beforeEach(() => { mocks.row = null; + mocks.summarizeLoadout.mockReset().mockReturnValue({ + stats: [{ id: 'firepower', value: 42, sourceCount: 3 }], + abilityStats: [ + { + abilityId: 'ability-1', + stats: [ + { + id: 'skill-power', + value: 18, + sourceCount: 1, + sources: [ + { + source: 'Skill Chip 1 primary', + chipIndex: 0, + chipSetId: 'chip-set-1', + value: 18, + }, + ], + }, + ], + }, + ], + activeBonuses: [ + { + name: 'Medic', + pieces: 2, + count: 3, + statId: 'skill-power', + value: 10, + runtime: selectedRuntime, + }, + ], + equippedTalents: [ + { + key: 'gear:Mask:talent-1', + id: 'talent-1', + runtime: selectedRuntime, + tier: 2, + tierLabel: 'TIER II', + source: 'Mask', + description: 'Restore 15% Armor.', + custom: { + name: 'Medic Protocol', + runtime: selectedRuntime, + }, + }, + ], + runtime: selectedRuntime, + }); mocks.loadCatalog.mockReset().mockResolvedValue({ - attributes: [], + attributes: [ + { + id: 'firepower', + name: 'Firepower', + unit: '%', + category: 'Core', + runtime: selectedRuntime, + }, + ], gear: [], gearAugments: [], brands: [], weapons: [], - talents: [], - chipSets: [], - protocols: [], - specializations: [], + talents: [ + { + id: 'talent-1', + name: 'Field Medic', + cooldown_seconds: 12, + runtime: selectedRuntime, + tiers: [{ runtime: selectedRuntime }], + }, + ], + chipSets: [ + { id: 'chip-set-1', talent: { runtime: selectedRuntime } }, + ], + protocols: [ + { id: 'protocol-1', talent: { runtime: selectedRuntime } }, + ], + specializations: [ + { + id: 'specialization-1', + focuses: [ + { + nodes: [ + { + ranks: [ + { combatEffects: [{ runtime: selectedRuntime }] }, + ], + }, + ], + }, + ], + }, + ], + mechanics, }); }); @@ -59,9 +234,82 @@ describe('shared Build access policy', () => { }); expect(result?.build.visibility).toBe(visibility); expect(result?.build.owner).toBe(false); + expect(result).not.toHaveProperty('mechanics'); }, ); + it('serializes rendered Build fields without selected Mechanic Programs', async () => { + mocks.row = buildRow('public'); + + const result = await loadSharedBuildProjection('field-medic-12345678', { + kind: 'public-preview', + }); + + expect(mocks.summarizeLoadout).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + mechanics, + talents: [expect.objectContaining({ runtime: selectedRuntime })], + }), + ); + expect(result?.summary).toEqual({ + stats: [{ id: 'firepower', value: 42, sourceCount: 3 }], + abilityStats: [ + { + abilityId: 'ability-1', + stats: [ + { + id: 'skill-power', + value: 18, + sourceCount: 1, + sources: [ + { + source: 'Skill Chip 1 primary', + chipIndex: 0, + chipSetId: 'chip-set-1', + value: 18, + }, + ], + }, + ], + }, + ], + activeBonuses: [ + { + name: 'Medic', + pieces: 2, + count: 3, + statId: 'skill-power', + value: 10, + }, + ], + equippedTalents: [ + { + id: 'talent-1', + tier: 2, + tierLabel: 'TIER II', + source: 'Mask', + description: 'Restore 15% Armor.', + custom: { name: 'Medic Protocol' }, + }, + ], + }); + expect(result?.attributes).toEqual([ + { + id: 'firepower', + name: 'Firepower', + unit: '%', + category: 'Core', + }, + ]); + expect(result?.talents).toEqual([ + { id: 'talent-1', name: 'Field Medic', cooldown_seconds: 12 }, + ]); + expect(serializedMechanicInternals(JSON.parse(JSON.stringify(result)))).toEqual( + [], + ); + }); + it('does not expose private Builds to public previews or other viewers', async () => { mocks.row = buildRow('private'); await expect( diff --git a/src/lib/server/shared-build.ts b/src/lib/server/shared-build.ts index fee9903..4b46e11 100644 --- a/src/lib/server/shared-build.ts +++ b/src/lib/server/shared-build.ts @@ -1,6 +1,9 @@ import { avatarUrl } from '$lib/avatar'; import { parseLoadoutDraft } from '$lib/builder/loadout'; -import { summarizeLoadout } from '$lib/builder/loadout-summary'; +import { + summarizeLoadout, + type LoadoutSummary, +} from '$lib/builder/loadout-summary'; import { getDatabase } from '$lib/server/database'; import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary'; @@ -8,6 +11,63 @@ export type SharedBuildAccessPolicy = { kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' }; type BuildVisibility = 'public' | 'unlisted' | 'private'; +function publicFields< + Value extends Record, + Field extends keyof Value, +>(value: Value, fields: readonly Field[]): Value { + return Object.fromEntries( + fields.map((field) => [field, value[field]]), + ) as Value; +} + +/** + * Builds are calculated with mechanic runtimes, but shared pages render only + * known values and materialized descriptions. Keep this as a whitelist so a + * future catalog or summary field cannot accidentally serialize a program. + */ +function publicLoadoutSummary(summary: LoadoutSummary): LoadoutSummary { + return { + stats: summary.stats.map((stat) => + publicFields(stat, ['id', 'value', 'sourceCount']), + ), + abilityStats: summary.abilityStats.map((ability) => ({ + abilityId: ability.abilityId, + stats: ability.stats.map((stat) => ({ + id: stat.id, + value: stat.value, + sourceCount: stat.sourceCount, + sources: stat.sources.map((source) => ({ + source: source.source, + chipIndex: source.chipIndex, + chipSetId: source.chipSetId, + value: source.value, + })), + })), + })), + activeBonuses: summary.activeBonuses.map((bonus) => + publicFields(bonus, ['name', 'pieces', 'count', 'statId', 'value']), + ), + equippedTalents: summary.equippedTalents.map((talent) => ({ + id: talent.id, + tier: talent.tier, + ...(talent.tierLabel === undefined + ? {} + : { tierLabel: talent.tierLabel }), + ...(talent.isExotic === undefined + ? {} + : { isExotic: talent.isExotic }), + ...(talent.abilityId === undefined + ? {} + : { abilityId: talent.abilityId }), + source: talent.source, + description: talent.description, + ...(talent.custom?.name === undefined + ? {} + : { custom: { name: talent.custom.name } }), + })), + } as LoadoutSummary; +} + export async function loadSharedBuildProjection( slug: string, access: SharedBuildAccessPolicy, @@ -92,13 +152,30 @@ export async function loadSharedBuildProjection( updatedAt: String(build.updated_at), }, loadout, - gear, - weapons, - chips, - protocols, - brands, - summary, - attributes: summaryCatalog.attributes, - talents: summaryCatalog.talents, + gear: gear.map((item) => + publicFields(item, ['id', 'name', 'slot', 'tier', 'base_armor_min']), + ), + weapons: weapons.map((item) => + publicFields(item, [ + 'id', + 'name', + 'category', + 'rpm', + 'magazine', + 'reload_speed', + 'damage_type', + 'exotic_talent_id', + ]), + ), + chips: chips.map((item) => publicFields(item, ['id', 'name'])), + protocols: protocols.map((item) => publicFields(item, ['id', 'name'])), + brands: brands.map((item) => publicFields(item, ['id', 'name'])), + summary: publicLoadoutSummary(summary), + attributes: summaryCatalog.attributes.map((item) => + publicFields(item, ['id', 'name', 'unit', 'category']), + ), + talents: summaryCatalog.talents.map((item) => + publicFields(item, ['id', 'name', 'cooldown_seconds']), + ), }; } diff --git a/src/routes/builder/+page.server.ts b/src/routes/builder/+page.server.ts index 26409b4..2ba1e14 100644 --- a/src/routes/builder/+page.server.ts +++ b/src/routes/builder/+page.server.ts @@ -8,6 +8,7 @@ import { publicOrigin } from '$lib/server/public-origin'; import type { Actions, PageServerLoad } from './$types'; import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses'; import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles'; +import { loadBuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime'; const validVisibility = new Set(['private', 'unlisted', 'public']); const slugify = (value: string) => @@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { specializationNodes, specializationRanks, specializationRankEffects, + specializationRankCombatEffects, specializationRankCosts, progression, rankProfiles, @@ -454,6 +456,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order', ) ).all(), + ( + await database.prepare(`SELECT attachment.*,effect.name AS definition_name + FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_node_ranks rank + ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specializations specialization + ON specialization.id=focus.specialization_id AND specialization.active=1 + JOIN specialization_combat_effects effect + ON effect.id=attachment.combat_effect_id + AND effect.specialization_id=focus.specialization_id + AND effect.active=1 + ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE, + node.row_number,node.column_number,attachment.rank, + attachment.effect_order,attachment.id`) + ).all(), ( await database.prepare( 'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id', @@ -470,6 +489,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => { ) ).all(), ]); + const mechanics = await loadBuildMechanicRuntimes(database, { + talentIds: talents.map((talent) => String(talent.id)), + protocolTalentIds: protocolTalents.map((talent) => String(talent.id)), + skillChipTalentIds: chipTalents.map((talent) => String(talent.id)), + specializationAttachmentIds: specializationRankCombatEffects.map((effect) => + String(effect.id), + ), + }); const iconUrl = ( type: | 'talent' @@ -488,6 +515,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { }; const configuredTalents = talents.map((talent) => ({ ...talent, + runtime: mechanics.talents[String(talent.id)] ?? null, iconUrl: iconUrl('talent', talent.id), template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? @@ -505,6 +533,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null, publicOrigin: publicOrigin(url.origin), initialBuild, + mechanics, attributes, coreRanges, gear: gear.map((item) => ({ @@ -584,6 +613,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent: talent ? { ...talent, + runtime: mechanics.skillChipTalents[String(talent.id)] ?? null, effects: chipTalentEffects.filter( (effect) => effect.talent_id === talent.id, ), @@ -614,6 +644,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { talent: talent ? { ...talent, + runtime: mechanics.protocolTalents[String(talent.id)] ?? null, effects: protocolTalentEffects.filter( (effect) => effect.talent_id === talent.id, ), @@ -664,6 +695,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => { effect.node_id === node.id && Number(effect.rank) === Number(rank.rank), ), + combatEffects: specializationRankCombatEffects + .filter( + (effect) => + effect.node_id === node.id && + Number(effect.rank) === Number(rank.rank), + ) + .map((effect) => ({ + ...effect, + definition: { + id: String(effect.combat_effect_id), + name: String(effect.definition_name), + }, + runtime: + mechanics.specializationAttachments[ + String(effect.id) + ] ?? null, + })), costs: specializationRankCosts.filter( (cost) => cost.node_id === node.id && diff --git a/src/routes/builder/builder-runtime-load.test.ts b/src/routes/builder/builder-runtime-load.test.ts new file mode 100644 index 0000000..69c5ab1 --- /dev/null +++ b/src/routes/builder/builder-runtime-load.test.ts @@ -0,0 +1,282 @@ +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime'; +import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection'; + +type HydratedCombatEffect = { + id: string; + definition: { id: string }; + runtime: MechanicRuntimeSelection | null; +}; + +const mocks = vi.hoisted(() => ({ + database: null as Database | null, + loadRuntimes: vi.fn(), +})); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => null), +})); +vi.mock('$lib/server/build-mechanic-runtime', () => ({ + loadBuildMechanicRuntimes: mocks.loadRuntimes, +})); + +import { load } from './+page.server'; + +const selected: MechanicRuntimeSelection = { + owner: { type: 'talent', id: 'source', scope: 'standard', hasTierII: true }, + sourceId: 'source', + sourceFingerprint: 'a'.repeat(64), + revisionId: 'revision', + revisionNumber: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + variants: [ + { + key: 'base', + label: 'BASE', + program: { + id: 'mechanic-runtime:["source","base"]', + name: 'Selected', + clauses: [], + }, + }, + ], +}; + +const runtimes: BuildMechanicRuntimes = { + talents: { 'talent-1': selected }, + protocolTalents: { 'protocol-talent-1': selected }, + skillChipTalents: { 'chip-talent-1': selected }, + specializationAttachments: { 'attachment-1': selected }, +}; + +const applySchema = async (database: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await database.exec(statement); + } +}; + +const seedSpecializationAttachments = async (database: Database) => { + await database.exec(` + INSERT INTO specializations(id,name,created_at,updated_at) VALUES + ('specialization-parent','Parent specialization','now','now'), + ('specialization-cross','Cross specialization','now','now'); + INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES('focus-parent','specialization-parent','Parent focus','Firepower',0,'now','now'); + INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES('node-parent','focus-parent','Parent node','',1,1,1,'now','now'); + INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-parent',1,''); + INSERT INTO specialization_combat_effects( + id,specialization_id,name,description_template,created_at,updated_at + ) VALUES + ('effect-valid-first','specialization-parent','Valid first','', 'now','now'), + ('effect-valid-second','specialization-parent','Valid second','', 'now','now'), + ('effect-cross','specialization-cross','Cross specialization effect','', 'now','now'); + INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES + ('attachment-valid-first','node-parent',1,'effect-valid-first',1, + 'enemy_hits_within_window',1,1,1,1), + ('attachment-valid-second','node-parent',1,'effect-valid-second',2, + 'enemy_hits_within_window',1,1,1,1), + ('attachment-cross','node-parent',1,'effect-cross',3, + 'enemy_hits_within_window',1,1,1,1); + `); +}; + +function builderDatabase() { + return { + prepare: vi.fn(async (sql: string) => ({ + get: vi.fn(async () => null), + all: vi.fn(async () => { + if (sql.includes('FROM talents WHERE active=1')) + return [{ id: 'talent-1', name: 'Talent' }]; + if (sql.includes('FROM skill_chip_sets WHERE active=1')) + return [ + { + id: 'chip-set', + name: 'Chip', + specialization_ability_id: 'ability', + }, + ]; + if (sql.includes('FROM skill_chip_talents')) + return [ + { + id: 'chip-talent-1', + skill_chip_set_id: 'chip-set', + name: 'Chip Talent', + }, + ]; + if (sql.includes('FROM os_protocols WHERE active=1')) + return [ + { + id: 'protocol-1', + name: 'Protocol', + quality: 'High-end', + primary_attribute_type: 'Health', + }, + ]; + if (sql.includes('FROM os_protocol_talents')) + return [ + { + id: 'protocol-talent-1', + protocol_id: 'protocol-1', + name: 'Protocol Talent', + }, + ]; + if (sql.includes('FROM specializations WHERE active=1')) + return [{ id: 'specialization-1', name: 'Spec' }]; + if (sql.includes('FROM specialization_focuses WHERE active=1')) + return [ + { + id: 'focus-1', + specialization_id: 'specialization-1', + display_order: 1, + name: 'Focus', + }, + ]; + if (sql.includes('FROM specialization_tree_nodes WHERE active=1')) + return [ + { + id: 'node-1', + focus_id: 'focus-1', + row_number: 1, + column_number: 1, + }, + ]; + if (sql.includes('FROM specialization_tree_node_ranks')) + return [{ node_id: 'node-1', rank: 1 }]; + if (sql.includes('FROM specialization_tree_rank_combat_effects')) + return [ + { + id: 'attachment-1', + node_id: 'node-1', + rank: 1, + combat_effect_id: 'reusable-effect', + effect_order: 1, + definition_name: 'Attached effect', + }, + ]; + return []; + }), + })), + } as unknown as Database; +} + +describe('Builder mechanic runtime hydration', () => { + beforeEach(() => { + mocks.database = builderDatabase(); + mocks.loadRuntimes.mockReset().mockResolvedValue(runtimes); + }); + + it('hydrates the same selected runtime capability shape as shared Builds', async () => { + const result = await load({ + cookies: {}, + url: new URL('https://shd.build/builder'), + } as never); + + expect(result).toBeDefined(); + if (!result) return; + expect(mocks.loadRuntimes).toHaveBeenCalledWith(mocks.database, { + talentIds: ['talent-1'], + protocolTalentIds: ['protocol-talent-1'], + skillChipTalentIds: ['chip-talent-1'], + specializationAttachmentIds: ['attachment-1'], + }); + expect(result.mechanics).toEqual(runtimes); + expect(result.talents[0]?.runtime).toBe(selected); + expect(result.chipSets[0]?.talent?.runtime).toBe(selected); + expect(result.protocols[0]?.talent?.runtime).toBe(selected); + expect( + result.specializations[0]?.focuses[0]?.nodes[0]?.ranks[0]?.combatEffects, + ).toMatchObject([ + { + id: 'attachment-1', + definition: { id: 'reusable-effect', name: 'Attached effect' }, + runtime: selected, + }, + ]); + }); +}); + +describe('Builder specialization combat-effect ownership', () => { + let database: Database; + + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + await seedSpecializationAttachments(database); + mocks.database = database; + }); + + afterEach(async () => { + await database.close(); + mocks.database = null; + }); + + it('hydrates only same-specialization effects in attachment order and does not select corrupt attachment runtimes', async () => { + const firstRuntime = { ...selected, sourceId: 'first-runtime' }; + const secondRuntime = { ...selected, sourceId: 'second-runtime' }; + mocks.loadRuntimes.mockReset().mockResolvedValue({ + talents: {}, + protocolTalents: {}, + skillChipTalents: {}, + specializationAttachments: { + 'attachment-valid-first': firstRuntime, + 'attachment-valid-second': secondRuntime, + }, + } satisfies BuildMechanicRuntimes); + + const result = await load({ + cookies: {}, + url: new URL('https://shd.build/builder'), + } as never); + + expect(result).toBeDefined(); + if (!result) return; + expect(mocks.loadRuntimes).toHaveBeenCalledWith(database, { + talentIds: [], + protocolTalentIds: [], + skillChipTalentIds: [], + specializationAttachmentIds: [ + 'attachment-valid-first', + 'attachment-valid-second', + ], + }); + const parentSpecialization = result.specializations.find( + (specialization: { id: string }) => + specialization.id === 'specialization-parent', + ); + const combatEffects = (parentSpecialization?.focuses[0]?.nodes[0]?.ranks[0] + ?.combatEffects ?? []) as HydratedCombatEffect[]; + expect(combatEffects.map((effect) => effect.id)).toEqual([ + 'attachment-valid-first', + 'attachment-valid-second', + ]); + expect(combatEffects.map((effect) => effect.definition.id)).toEqual([ + 'effect-valid-first', + 'effect-valid-second', + ]); + expect(combatEffects.map((effect) => effect.runtime)).toEqual([ + firstRuntime, + secondRuntime, + ]); + }); +}); diff --git a/src/routes/database/+page.server.ts b/src/routes/database/+page.server.ts index 1f8384a..3037160 100644 --- a/src/routes/database/+page.server.ts +++ b/src/routes/database/+page.server.ts @@ -1,6 +1,16 @@ import { getDatabase } from '$lib/server/database'; import { getRegistrySearch } from '$lib/server/pagination'; import { databaseCategories } from '$lib/database/categories'; +import { + loadMechanicPublicProjectionsForOwners, + type MechanicPublicProjectionOwnerRef, + type MechanicPublicProjectionsByOwner, +} from '$lib/server/mechanic-public-projection'; +import { + databaseMechanicGroup, + databaseMechanicOwnersMatchSearch, + databaseMechanicSearchSummary, +} from './public-mechanics'; import type { PageServerLoad } from './$types'; const tables = { @@ -28,110 +38,465 @@ export const load: PageServerLoad = async ({ url }) => { return { ...category, count: Number(row?.count ?? 0) }; }), ); - const searchResults = search + const rawSearchResults = search ? await ( await database.prepare(` - SELECT * FROM ( - SELECT 'gear' AS slug,'Gear' AS category,item.name, - item.slot || ' · T' || item.tier AS detail - FROM gear item - WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE) - UNION ALL - SELECT 'weapons','Weapons',item.name, - item.category || ' · ' || item.damage_type || ' damage' - FROM weapons item - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE - OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - UNION ALL - SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type - FROM weapon_mods item - WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE) - UNION ALL - SELECT 'gear-mods','Gear Mods',item.name,'Gear modification' - FROM gear_mods item - WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE - UNION ALL - SELECT 'os-protocols','OS Protocols',item.name, - item.quality || ' · ' || item.primary_attribute_type - FROM os_protocols item - LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE - OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE - OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - UNION ALL - SELECT 'talents','Talents',item.name,item.category - FROM talents item - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE - OR EXISTS ( - SELECT 1 FROM talent_tiers tier - WHERE tier.talent_id=item.id AND ( - tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE - OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE - OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - ) - ) - UNION ALL - SELECT 'brands','Brands',item.name, - COALESCE(( - SELECT GROUP_CONCAT(attribute.name, ', ') - FROM brand_set_effects effect - JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id - WHERE effect.brand_set_id=item.id - ),'Brand Set bonuses') - FROM brand_sets item - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE - OR EXISTS ( - SELECT 1 FROM brand_set_effects effect - JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id - WHERE effect.brand_set_id=item.id AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - ) - UNION ALL - SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set') - FROM skill_chip_sets item - LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE - OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - UNION ALL - SELECT 'specializations','Specializations',item.name,'Specialization' - FROM specializations item - WHERE item.active=1 AND ( - item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.description LIKE ? ESCAPE '\\' COLLATE NOCASE - OR EXISTS ( - SELECT 1 FROM specialization_focuses focus - WHERE focus.specialization_id=item.id AND focus.active=1 - AND focus.name LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - OR EXISTS ( - SELECT 1 FROM specialization_abilities ability - WHERE ability.specialization_id=item.id AND ability.active=1 - AND ability.name LIKE ? ESCAPE '\\' COLLATE NOCASE - ) - ) - ) matches - ORDER BY name COLLATE NOCASE - LIMIT 40 - `) - ).all(...Array(27).fill(pattern)) + SELECT * FROM ( + SELECT 'gear' AS slug,'Gear' AS category,item.id AS parent_id,item.name, + item.slot || ' · T' || item.tier AS detail + FROM gear item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM gear_talents link + JOIN talents linked_talent + ON linked_talent.id=link.talent_id AND linked_talent.active=1 + JOIN mechanic_authored_sources source + ON source.owner_type='talent' AND source.owner_id=linked_talent.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE link.gear_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + UNION ALL + SELECT 'weapons','Weapons',item.id,item.name, + item.category || ' · ' || item.damage_type || ' damage' + FROM weapons item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM talents linked_talent + JOIN mechanic_authored_sources source + ON source.owner_type='talent' AND source.owner_id=linked_talent.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE linked_talent.active=1 + AND ( + linked_talent.id=item.exotic_talent_id + OR EXISTS ( + SELECT 1 FROM weapon_talent_options link + WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id + ) + ) + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + UNION ALL + SELECT 'weapon-mods','Weapon Mods',item.id,item.name,item.slot_type + FROM weapon_mods item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + UNION ALL + SELECT 'gear-mods','Gear Mods',item.id,item.name,'Gear modification' + FROM gear_mods item + WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + UNION ALL + SELECT 'os-protocols','OS Protocols',item.id,item.name, + item.quality || ' · ' || item.primary_attribute_type + FROM os_protocols item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM os_protocol_talents talent + WHERE talent.protocol_id=item.id AND ( + talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM mechanic_authored_sources source + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE source.owner_type='os_protocol_talent' AND source.owner_id=talent.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + ) + ) + UNION ALL + SELECT 'talents','Talents',item.id,item.name,item.category + FROM talents item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM talent_tiers tier + WHERE tier.talent_id=item.id AND ( + tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE + OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE + OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + OR EXISTS ( + SELECT 1 FROM mechanic_authored_sources source + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE source.owner_type='talent' AND source.owner_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + UNION ALL + SELECT 'brands','Brands',item.id,item.name, + COALESCE(( + SELECT GROUP_CONCAT(attribute.name, ', ') + FROM brand_set_effects effect + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id + WHERE effect.brand_set_id=item.id + ),'Brand Set bonuses') + FROM brand_sets item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM brand_set_effects effect + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id + WHERE effect.brand_set_id=item.id + AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + UNION ALL + SELECT 'skill-chips','Skill Chips',item.id,item.name, + COALESCE((SELECT talent.name FROM skill_chip_talents talent + WHERE talent.skill_chip_set_id=item.id),'Skill Chip set') + FROM skill_chip_sets item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM skill_chip_talents talent + WHERE talent.skill_chip_set_id=item.id AND ( + talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM mechanic_authored_sources source + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE source.owner_type='skill_chip_talent' AND source.owner_id=talent.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + ) + ) + UNION ALL + SELECT 'specializations','Specializations',item.id,item.name,'Specialization' + FROM specializations item + WHERE item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR item.description LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM specialization_focuses focus + WHERE focus.specialization_id=item.id AND focus.active=1 + AND focus.name LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + OR EXISTS ( + SELECT 1 FROM specialization_abilities ability + WHERE ability.specialization_id=item.id AND ability.active=1 + AND ability.name LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + OR EXISTS ( + SELECT 1 FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specialization_combat_effects combat_effect + ON combat_effect.id=attachment.combat_effect_id + AND combat_effect.specialization_id=focus.specialization_id + AND combat_effect.active=1 + JOIN mechanic_authored_sources source + ON source.owner_type='specialization_tree_rank' AND source.owner_id=attachment.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE focus.specialization_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + ) + ) matches + ORDER BY name COLLATE NOCASE + `) + ).all(...Array(33).fill(pattern)) : []; + // Raw projection text produces a candidate set only. Canonical projections + // below decide behavior matches; this query preserves independent metadata matches. + const metadataSearchResultKeys = search + ? new Set( + ( + await ( + await database.prepare(`WITH search(pattern) AS (VALUES (?)) + SELECT 'gear' AS slug,item.id AS parent_id + FROM gear item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.slot LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + UNION ALL + SELECT 'weapons',item.id + FROM weapons item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.category LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.damage_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + UNION ALL + SELECT 'weapon-mods',item.id + FROM weapon_mods item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.slot_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + UNION ALL + SELECT 'gear-mods',item.id + FROM gear_mods item,search + WHERE item.active=1 + AND item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + UNION ALL + SELECT 'os-protocols',item.id + FROM os_protocols item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.quality LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.primary_attribute_type LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM os_protocol_talents talent + WHERE talent.protocol_id=item.id AND ( + talent.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR talent.description_template LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + ) + ) + UNION ALL + SELECT 'talents',item.id + FROM talents item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.category LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM talent_tiers tier + WHERE tier.talent_id=item.id AND ( + tier.description LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR tier.trigger_text LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR tier.effect_text LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + ) + ) + UNION ALL + SELECT 'brands',item.id + FROM brand_sets item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM brand_set_effects effect + JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id + WHERE effect.brand_set_id=item.id + AND attribute.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + ) + UNION ALL + SELECT 'skill-chips',item.id + FROM skill_chip_sets item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM skill_chip_talents talent + WHERE talent.skill_chip_set_id=item.id AND ( + talent.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR talent.description_template LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + ) + ) + UNION ALL + SELECT 'specializations',item.id + FROM specializations item,search + WHERE item.active=1 AND ( + item.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR item.description LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM specialization_focuses focus + WHERE focus.specialization_id=item.id AND focus.active=1 + AND focus.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + OR EXISTS ( + SELECT 1 FROM specialization_abilities ability + WHERE ability.specialization_id=item.id AND ability.active=1 + AND ability.name LIKE search.pattern ESCAPE '\\' COLLATE NOCASE + ) + )`) + ).all(pattern) + ).map((result) => `${result.slug}\u0000${result.parent_id}`), + ) + : new Set(); + const resultIds = (slug: string) => + rawSearchResults + .filter((result) => String(result.slug) === slug) + .map((result) => String(result.parent_id)); + const [ + gearTalentRows, + weaponTalentRows, + protocolTalentRows, + skillChipTalentRows, + specializationAttachmentRows, + ] = await Promise.all([ + resultIds('gear').length + ? ( + await database.prepare(`SELECT link.gear_id,link.talent_id FROM gear_talents link + JOIN talents ON talents.id=link.talent_id AND talents.active=1 + WHERE link.gear_id IN (${resultIds('gear') + .map(() => '?') + .join(',')}) + ORDER BY link.gear_id,link.talent_id`) + ).all(...resultIds('gear')) + : [], + resultIds('weapons').length + ? ( + await database.prepare(`SELECT relation.weapon_id,relation.talent_id FROM ( + SELECT weapon_id,talent_slot,talent_id FROM weapon_talent_options + UNION ALL + SELECT id AS weapon_id,NULL AS talent_slot,exotic_talent_id AS talent_id + FROM weapons WHERE exotic_talent_id IS NOT NULL + ) relation + JOIN talents ON talents.id=relation.talent_id AND talents.active=1 + WHERE relation.weapon_id IN (${resultIds('weapons') + .map(() => '?') + .join(',')}) + ORDER BY relation.weapon_id,relation.talent_slot,relation.talent_id`) + ).all(...resultIds('weapons')) + : [], + resultIds('os-protocols').length + ? ( + await database.prepare(`SELECT protocol_id,id FROM os_protocol_talents + WHERE protocol_id IN (${resultIds('os-protocols') + .map(() => '?') + .join(',')}) + ORDER BY protocol_id,id`) + ).all(...resultIds('os-protocols')) + : [], + resultIds('skill-chips').length + ? ( + await database.prepare(`SELECT skill_chip_set_id,id FROM skill_chip_talents + WHERE skill_chip_set_id IN (${resultIds('skill-chips') + .map(() => '?') + .join(',')}) + ORDER BY skill_chip_set_id,id`) + ).all(...resultIds('skill-chips')) + : [], + resultIds('specializations').length + ? ( + await database.prepare(`SELECT attachment.id,focus.specialization_id + FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specialization_combat_effects combat_effect + ON combat_effect.id=attachment.combat_effect_id + AND combat_effect.specialization_id=focus.specialization_id + AND combat_effect.active=1 + WHERE focus.specialization_id IN (${resultIds('specializations') + .map(() => '?') + .join(',')}) + ORDER BY focus.specialization_id,focus.display_order,focus.name COLLATE NOCASE, + node.row_number,node.column_number,attachment.rank,attachment.effect_order`) + ).all(...resultIds('specializations')) + : [], + ]); + const resultKey = (slug: string, id: string) => `${slug}\u0000${id}`; + const ownersByResult = new Map(); + const addOwner = ( + slug: string, + parentId: string, + owner: MechanicPublicProjectionOwnerRef, + ) => { + const key = resultKey(slug, parentId); + ownersByResult.set(key, [...(ownersByResult.get(key) ?? []), owner]); + }; + for (const result of rawSearchResults) { + if (String(result.slug) === 'talents') { + addOwner('talents', String(result.parent_id), { + type: 'talent', + id: String(result.parent_id), + }); + } + } + for (const row of gearTalentRows) { + addOwner('gear', String(row.gear_id), { + type: 'talent', + id: String(row.talent_id), + }); + } + for (const row of weaponTalentRows) { + addOwner('weapons', String(row.weapon_id), { + type: 'talent', + id: String(row.talent_id), + }); + } + for (const row of protocolTalentRows) { + addOwner('os-protocols', String(row.protocol_id), { + type: 'os_protocol_talent', + id: String(row.id), + }); + } + for (const row of skillChipTalentRows) { + addOwner('skill-chips', String(row.skill_chip_set_id), { + type: 'skill_chip_talent', + id: String(row.id), + }); + } + for (const row of specializationAttachmentRows) { + addOwner('specializations', String(row.specialization_id), { + type: 'specialization_tree_rank', + id: String(row.id), + }); + } + const owners = [ + ...new Map( + [...ownersByResult.values()] + .flat() + .map((owner) => [`${owner.type}\u0000${owner.id}`, owner]), + ).values(), + ]; + const publicMechanics: MechanicPublicProjectionsByOwner = owners.length + ? await loadMechanicPublicProjectionsForOwners(database, owners) + : ({} as MechanicPublicProjectionsByOwner); + const searchResults = rawSearchResults + .filter( + (result) => + metadataSearchResultKeys.has( + resultKey(String(result.slug), String(result.parent_id)), + ) || + databaseMechanicOwnersMatchSearch( + ownersByResult.get( + resultKey(String(result.slug), String(result.parent_id)), + ) ?? [], + publicMechanics, + search, + ), + ) + .slice(0, 40); + const normalizedSearch = search.toLocaleLowerCase(); return { registries: counts, total: counts.reduce((total, registry) => total + registry.count, 0), search, - searchResults: searchResults.map((result) => ({ - slug: String(result.slug), - category: String(result.category), - name: String(result.name), - detail: String(result.detail ?? ''), - })), + searchResults: searchResults.map((result) => { + const groups = ( + ownersByResult.get( + resultKey(String(result.slug), String(result.parent_id)), + ) ?? [] + ) + .map((owner) => + databaseMechanicGroup(owner, publicMechanics, { label: 'BEHAVIOR' }), + ) + .filter((group) => group !== null); + const group = + groups.find((candidate) => + candidate.variants.some((variant) => + variant.behaviorText.toLocaleLowerCase().includes(normalizedSearch), + ), + ) ?? + groups[0] ?? + null; + const behavior = databaseMechanicSearchSummary(group); + return { + slug: String(result.slug), + category: String(result.category), + name: String(result.name), + detail: String(result.detail ?? ''), + ...(behavior ?? {}), + }; + }), }; }; diff --git a/src/routes/database/+page.svelte b/src/routes/database/+page.svelte index f0f16f9..de9aef6 100644 --- a/src/routes/database/+page.svelte +++ b/src/routes/database/+page.svelte @@ -1,6 +1,21 @@ @@ -31,11 +46,13 @@ {#if data.searchResults.length} diff --git a/src/routes/database/[category]/+page.server.ts b/src/routes/database/[category]/+page.server.ts index 0d76b76..acc0fb2 100644 --- a/src/routes/database/[category]/+page.server.ts +++ b/src/routes/database/[category]/+page.server.ts @@ -1,5 +1,5 @@ import { databaseCategory } from '$lib/database/categories'; -import type { DatabaseFact, DatabaseItem } from '$lib/database/items'; +import type { DatabaseItem, DatabaseMechanicGroup } from '$lib/database/items'; import type { GameIconName } from '$lib/game-icons'; import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout'; import { @@ -44,6 +44,15 @@ import { publicWeaponModSlots, weaponModSlotDefinitionByName, } from '$lib/weapons/mod-slots'; +import { + loadMechanicPublicProjectionsForOwners, + type MechanicPublicProjectionOwnerRef, + type MechanicPublicProjectionsByOwner, +} from '$lib/server/mechanic-public-projection'; +import { + databaseMechanicGroup, + databaseMechanicOwnersMatchSearch, +} from '../public-mechanics'; const number = (value: unknown) => { const numeric = Number(value); @@ -65,19 +74,9 @@ const weaponIcon = (category: unknown) => String(category).toLowerCase() as GameIconName; const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) => updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : ''; -const parsedObject = (value: unknown): Record => { - try { - const parsed = JSON.parse(String(value ?? '{}')); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {}; - } catch { - return {}; - } -}; const talentCardItem = ( - row: Record, - mechanics: Array>, + row: Record, + mechanicGroups: DatabaseMechanicGroup[], weaponSlotOverride?: number, ): DatabaseItem => { const talentCategory = String(row.category); @@ -91,27 +90,6 @@ const talentCardItem = ( isWeaponTalent && weaponSlot ? `WEAPON ${weaponSlot}` : normalizedCategory.toUpperCase(); - const afterLines: DatabaseFact[] = [ - ...(row.duration_seconds - ? [ - { - label: 'EFFECT DURATION', - value: `${number(row.duration_seconds)} SECONDS`, - }, - ] - : []), - ...(Number(row.max_stacks) > 1 - ? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }] - : []), - ...(row.cooldown_seconds - ? [ - { - label: 'TALENT COOLDOWN', - value: `${number(row.cooldown_seconds)} SECONDS`, - }, - ] - : []), - ]; const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent'; const destination = isWeaponTalent ? 'weapons' : 'gear'; @@ -136,14 +114,7 @@ const talentCardItem = ( : []), ], facts: [], - description: row.tier_i - ? String(row.tier_i) - : 'Description is not available.', - secondaryDescription: row.tier_ii - ? String(row.tier_ii) - : 'Description is not available.', - descriptionStyle: 'data', - afterLines, + mechanicGroups, actions: [ { label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`, @@ -179,6 +150,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { }> = []; let countSql = ''; let rowsSql = ''; + let behaviorSearchCandidateSql: string | null = null; switch (category.slug) { case 'gear': { @@ -195,7 +167,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ).all(), ( await database.prepare(`SELECT talents.id,talents.name, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description, icons.updated_at FROM talents LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest') @@ -224,8 +195,19 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { const conditions = ['item.active=1']; queryArgs = []; if (search) { - conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE"); - queryArgs.push(pattern); + conditions.push(`( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM gear_talents linked_talent + JOIN talents ON talents.id=linked_talent.talent_id AND talents.active=1 + JOIN mechanic_authored_sources source + ON source.owner_type='talent' AND source.owner_id=talents.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE linked_talent.gear_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )`); + queryArgs.push(pattern, pattern); } if (selectedSlots.length) { conditions.push( @@ -244,6 +226,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { queryArgs.push(...selectedTalents); } const where = conditions.join(' AND '); + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM gear item WHERE ${where}` + : null; countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`; rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at FROM gear item @@ -282,7 +267,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { options: talentRows.map((row) => ({ value: String(row.id), label: String(row.name), - description: String(row.tier_i_description ?? ''), iconUrl: iconUrl('talent', row.id, row.updated_at), })), }, @@ -291,13 +275,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { } case 'weapons': { const talentRows = await ( - await database.prepare(`SELECT DISTINCT talents.id,talents.name, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description, - icons.updated_at - FROM weapon_talent_options options - JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1 - JOIN talents ON talents.id=options.talent_id AND talents.active=1 + await database.prepare(`SELECT talents.id,talents.name,icons.updated_at + FROM talents LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id + WHERE talents.active=1 AND ( + EXISTS ( + SELECT 1 FROM weapon_talent_options options + JOIN weapons source_weapons ON source_weapons.id=options.weapon_id + WHERE source_weapons.active=1 AND options.talent_id=talents.id + ) + OR EXISTS ( + SELECT 1 FROM weapons source_weapons + WHERE source_weapons.active=1 AND source_weapons.exotic_talent_id=talents.id + ) + ) ORDER BY talents.name COLLATE NOCASE`) ).all(); const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG']; @@ -315,8 +306,25 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { const conditions = ['item.active=1']; queryArgs = []; if (search) { - conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE"); - queryArgs.push(pattern); + conditions.push(`( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM talents linked_talent + JOIN mechanic_authored_sources source + ON source.owner_type='talent' AND source.owner_id=linked_talent.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE linked_talent.active=1 + AND ( + linked_talent.id=item.exotic_talent_id + OR EXISTS ( + SELECT 1 FROM weapon_talent_options link + WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id + ) + ) + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )`); + queryArgs.push(pattern, pattern); } if (selectedTypes.length) { conditions.push( @@ -331,11 +339,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { queryArgs.push(...selectedDamageTypes); } if (selectedTalents.length) { - conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents - WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`); - queryArgs.push(...selectedTalents); + conditions.push(`( + EXISTS ( + SELECT 1 FROM weapon_talent_options filter_talents + WHERE filter_talents.weapon_id=item.id + AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}) + ) + OR item.exotic_talent_id IN (${selectedTalents.map(() => '?').join(',')}) + )`); + queryArgs.push(...selectedTalents, ...selectedTalents); } const where = conditions.join(' AND '); + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM weapons item WHERE ${where}` + : null; countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`; rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at, native.damage_min,native.damage_max,native.reference_level, @@ -377,7 +394,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { options: talentRows.map((row) => ({ value: String(row.id), label: String(row.name), - description: String(row.tier_i_description ?? ''), iconUrl: iconUrl('talent', row.id, row.updated_at), })), }, @@ -409,10 +425,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { WHERE item.active=1${searchClause} ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; break; - case 'os-protocols': - countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`; + case 'os-protocols': { + const where = search + ? `item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM os_protocol_talents talent + JOIN mechanic_authored_sources source + ON source.owner_type='os_protocol_talent' AND source.owner_id=talent.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE talent.protocol_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )` + : 'item.active=1'; + queryArgs = search ? [pattern, pattern] : []; + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM os_protocols item WHERE ${where}` + : null; + countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE ${where}`; rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit, - primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at, + primary_effect.base_value,talents.id AS talent_id,talents.name AS talent_name,icons.updated_at AS icon_updated_at, render.id AS render_id,render.updated_at AS render_updated_at FROM os_protocols item LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id @@ -420,9 +453,10 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id LEFT JOIN managed_images render ON render.source_entity_type='os_protocol_render' AND render.source_entity_id=item.id - WHERE item.active=1${searchClause} + WHERE ${where} ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`; break; + } case 'talents': { const talentSlots = [ { value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const }, @@ -448,8 +482,16 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { const conditions = ['item.active=1']; queryArgs = []; if (search) { - conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE"); - queryArgs.push(pattern); + conditions.push(`( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM mechanic_authored_sources source + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE source.owner_type='talent' AND source.owner_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )`); + queryArgs.push(pattern, pattern); } if (selectedTalentSlots.length) { const slotConditions = selectedTalentSlots.map((slot) => { @@ -467,10 +509,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ); } const where = conditions.join(' AND '); + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM talents item WHERE ${where}` + : null; countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`; rowsSql = `SELECT item.*, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii, (SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot, (SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope, icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at @@ -499,10 +542,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { WHERE item.active=1${searchClause} ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; break; - case 'skill-chips': - countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`; + case 'skill-chips': { + const where = search + ? `item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM skill_chip_talents talent + JOIN mechanic_authored_sources source + ON source.owner_type='skill_chip_talent' AND source.owner_id=talent.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE talent.skill_chip_set_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )` + : 'item.active=1'; + queryArgs = search ? [pattern, pattern] : []; + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM skill_chip_sets item WHERE ${where}` + : null; + countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE ${where}`; rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit, - talents.name AS talent_name,talents.description_template AS talent_description, + talents.id AS talent_id,talents.name AS talent_name, icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at FROM skill_chip_sets item LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id @@ -510,11 +570,35 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id - WHERE item.active=1${searchClause} + WHERE ${where} ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; break; - case 'specializations': - countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE item.active=1${searchClause}`; + } + case 'specializations': { + const where = search + ? `item.active=1 AND ( + item.name LIKE ? ESCAPE '\\' COLLATE NOCASE + OR EXISTS ( + SELECT 1 FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specialization_combat_effects combat_effect + ON combat_effect.id=attachment.combat_effect_id + AND combat_effect.specialization_id=focus.specialization_id + AND combat_effect.active=1 + JOIN mechanic_authored_sources source + ON source.owner_type='specialization_tree_rank' AND source.owner_id=attachment.id + JOIN mechanic_public_variants variant ON variant.source_id=source.id + WHERE focus.specialization_id=item.id + AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE + ) + )` + : 'item.active=1'; + queryArgs = search ? [pattern, pattern] : []; + behaviorSearchCandidateSql = search + ? `SELECT item.id,item.name FROM specializations item WHERE ${where}` + : null; + countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE ${where}`; rowsSql = `SELECT item.*, (SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.active=1) AS focus_count, (SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.builder_ready=1 AND focus.active=1) AS ready_focus_count, @@ -536,41 +620,180 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { FROM specializations item LEFT JOIN game_data_icons icons ON icons.entity_type='specialization' AND icons.entity_id=item.id LEFT JOIN managed_images render ON render.source_entity_type='specialization_render' AND render.source_entity_id=item.id - WHERE item.active=1${searchClause} + WHERE ${where} ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`; break; + } } - const total = Number( - (await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0, - ); + // Cache text may identify candidates, but only canonical projections decide + // whether behavior matches and therefore contributes to totals or pages. + let validatedBehaviorSearchIds: string[] | null = null; + if (behaviorSearchCandidateSql) { + const candidates = await ( + await database.prepare(behaviorSearchCandidateSql) + ).all(...queryArgs); + const candidateIds = candidates.map((candidate) => String(candidate.id)); + const ownersByCandidate = new Map< + string, + MechanicPublicProjectionOwnerRef[] + >(); + const addOwner = ( + parentId: unknown, + owner: MechanicPublicProjectionOwnerRef, + ) => { + const id = String(parentId); + ownersByCandidate.set(id, [...(ownersByCandidate.get(id) ?? []), owner]); + }; + if (candidateIds.length) { + let ownerRows: Array> = []; + const placeholders = candidateIds.map(() => '?').join(','); + switch (category.slug) { + case 'gear': + ownerRows = await ( + await database.prepare(`SELECT links.gear_id AS parent_id,links.talent_id AS owner_id + FROM gear_talents links + JOIN talents ON talents.id=links.talent_id AND talents.active=1 + WHERE links.gear_id IN (${placeholders})`) + ).all(...candidateIds); + for (const row of ownerRows) + addOwner(row.parent_id, { + type: 'talent', + id: String(row.owner_id), + }); + break; + case 'weapons': + ownerRows = await ( + await database.prepare(`SELECT relation.weapon_id AS parent_id,relation.talent_id AS owner_id + FROM ( + SELECT weapon_id,talent_id FROM weapon_talent_options + UNION ALL + SELECT id AS weapon_id,exotic_talent_id AS talent_id + FROM weapons WHERE exotic_talent_id IS NOT NULL + ) relation + JOIN talents ON talents.id=relation.talent_id AND talents.active=1 + WHERE relation.weapon_id IN (${placeholders})`) + ).all(...candidateIds); + for (const row of ownerRows) + addOwner(row.parent_id, { + type: 'talent', + id: String(row.owner_id), + }); + break; + case 'os-protocols': + ownerRows = await ( + await database.prepare(`SELECT protocol_id AS parent_id,id AS owner_id + FROM os_protocol_talents WHERE protocol_id IN (${placeholders})`) + ).all(...candidateIds); + for (const row of ownerRows) + addOwner(row.parent_id, { + type: 'os_protocol_talent', + id: String(row.owner_id), + }); + break; + case 'talents': + for (const id of candidateIds) addOwner(id, { type: 'talent', id }); + break; + case 'skill-chips': + ownerRows = await ( + await database.prepare(`SELECT skill_chip_set_id AS parent_id,id AS owner_id + FROM skill_chip_talents WHERE skill_chip_set_id IN (${placeholders})`) + ).all(...candidateIds); + for (const row of ownerRows) + addOwner(row.parent_id, { + type: 'skill_chip_talent', + id: String(row.owner_id), + }); + break; + case 'specializations': + ownerRows = await ( + await database.prepare(`SELECT focus.specialization_id AS parent_id, + attachment.id AS owner_id + FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specialization_combat_effects combat_effect + ON combat_effect.id=attachment.combat_effect_id + AND combat_effect.specialization_id=focus.specialization_id + AND combat_effect.active=1 + WHERE focus.specialization_id IN (${placeholders})`) + ).all(...candidateIds); + for (const row of ownerRows) + addOwner(row.parent_id, { + type: 'specialization_tree_rank', + id: String(row.owner_id), + }); + break; + } + } + const owners = [ + ...new Map( + [...ownersByCandidate.values()] + .flat() + .map((owner) => [`${owner.type}\u0000${owner.id}`, owner]), + ).values(), + ] as MechanicPublicProjectionOwnerRef[]; + const projections: MechanicPublicProjectionsByOwner = owners.length + ? await loadMechanicPublicProjectionsForOwners(database, owners) + : ({} as MechanicPublicProjectionsByOwner); + const normalizedSearch = search.toLocaleLowerCase(); + validatedBehaviorSearchIds = candidates + .filter( + (candidate) => + String(candidate.name) + .toLocaleLowerCase() + .includes(normalizedSearch) || + databaseMechanicOwnersMatchSearch( + ownersByCandidate.get(String(candidate.id)) ?? [], + projections, + search, + ), + ) + .map((candidate) => String(candidate.id)); + } + + const total = + validatedBehaviorSearchIds?.length ?? + Number( + (await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0, + ); const pagination = getPagination(url, total); - const rows = await ( - await database.prepare(rowsSql) - ).all(...queryArgs, pagination.perPage, pagination.offset); - const protocolTalentEffects = - category.slug === 'os-protocols' && rows.length - ? await ( - await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit - FROM os_protocol_talents talents - JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id - JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id - WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')}) - ORDER BY talents.protocol_id,effects.sort_order`) - ).all(...rows.map((row) => row.id)) - : []; + const rows = + validatedBehaviorSearchIds !== null && !validatedBehaviorSearchIds.length + ? [] + : await ( + await database.prepare( + validatedBehaviorSearchIds + ? `${rowsSql.slice(0, rowsSql.lastIndexOf('ORDER BY'))}AND item.id IN (${validatedBehaviorSearchIds + .map(() => '?') + .join( + ',', + )}) ${rowsSql.slice(rowsSql.lastIndexOf('ORDER BY'))}` + : rowsSql, + ) + ).all( + ...queryArgs, + ...(validatedBehaviorSearchIds ?? []), + pagination.perPage, + pagination.offset, + ); const weaponRelations = category.slug === 'weapons' && rows.length ? await ( - await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii, + await database.prepare(`SELECT relation.weapon_id,relation.talent_slot,relation.kind AS relation_kind, + talents.*,COALESCE(scopes.scope,CASE relation.kind WHEN 'exotic' THEN 'Exotic' END) AS weapon_scope, icons.updated_at AS icon_updated_at - FROM weapon_talent_options options - JOIN talents ON talents.id=options.talent_id AND talents.active=1 + FROM ( + SELECT weapon_id,talent_slot,talent_id,'standard' AS kind FROM weapon_talent_options + UNION ALL + SELECT id AS weapon_id,NULL AS talent_slot,exotic_talent_id AS talent_id,'exotic' AS kind + FROM weapons WHERE exotic_talent_id IS NOT NULL + ) relation + JOIN talents ON talents.id=relation.talent_id AND talents.active=1 + LEFT JOIN talent_weapon_scopes scopes ON scopes.talent_id=talents.id LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id - WHERE options.weapon_id IN (${rows.map(() => '?').join(',')}) - ORDER BY options.talent_slot,talents.name COLLATE NOCASE`) + WHERE relation.weapon_id IN (${rows.map(() => '?').join(',')}) + ORDER BY relation.weapon_id,relation.kind,relation.talent_slot,talents.name COLLATE NOCASE`) ).all(...rows.map((row) => row.id)) : []; const weaponModSlotRows = @@ -594,16 +817,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ? await ( await database.prepare(`SELECT relation.*,icons.updated_at FROM ( - SELECT links.gear_id,'brand' AS kind,brands.id,brands.name, - NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks, - NULL AS tier_i,NULL AS tier_ii + SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,NULL AS category FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id WHERE brands.active=1 UNION ALL - SELECT links.gear_id,'talent' AS kind,talents.id,talents.name, - talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i, - (SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii + SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,talents.category FROM gear_talents links JOIN talents ON talents.id=links.talent_id WHERE talents.active=1 ) relation @@ -640,40 +858,113 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`) ).all(...rows.map((row) => row.id)) : []; - const talentIds = [ - ...new Set( - category.slug === 'talents' - ? rows.map((row) => String(row.id)) - : category.slug === 'weapons' - ? weaponRelations.map((relation) => String(relation.id)) - : category.slug === 'gear' - ? gearRelations - .filter((relation) => relation.kind === 'talent') - .map((relation) => String(relation.id)) - : [], - ), + const specializationMechanicRows = + category.slug === 'specializations' && rows.length + ? await ( + await database.prepare(`SELECT attachment.id AS attachment_id, + focus.specialization_id,focus.name AS focus_name,focus.display_order AS focus_display_order, + node.name AS node_name,node.row_number,node.column_number, + attachment.rank,attachment.effect_order,combat_effect.name AS combat_effect_name + FROM specialization_tree_rank_combat_effects attachment + JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1 + JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1 + JOIN specialization_combat_effects combat_effect + ON combat_effect.id=attachment.combat_effect_id + AND combat_effect.specialization_id=focus.specialization_id + AND combat_effect.active=1 + WHERE focus.specialization_id IN (${rows.map(() => '?').join(',')}) + ORDER BY focus.display_order,focus.name COLLATE NOCASE,node.row_number,node.column_number, + attachment.rank,attachment.effect_order`) + ).all(...rows.map((row) => row.id)) + : []; + const mechanicOwners = [ + ...(category.slug === 'talents' + ? rows.map((row) => ({ type: 'talent' as const, id: String(row.id) })) + : []), + ...(category.slug === 'weapons' + ? weaponRelations.map((row) => ({ + type: 'talent' as const, + id: String(row.id), + })) + : []), + ...(category.slug === 'gear' + ? gearRelations + .filter((row) => row.kind === 'talent') + .map((row) => ({ type: 'talent' as const, id: String(row.id) })) + : []), + ...(category.slug === 'os-protocols' + ? rows + .filter((row) => row.talent_id) + .map((row) => ({ + type: 'os_protocol_talent' as const, + id: String(row.talent_id), + })) + : []), + ...(category.slug === 'skill-chips' + ? rows + .filter((row) => row.talent_id) + .map((row) => ({ + type: 'skill_chip_talent' as const, + id: String(row.talent_id), + })) + : []), + ...specializationMechanicRows.map((row) => ({ + type: 'specialization_tree_rank' as const, + id: String(row.attachment_id), + })), ]; - const talentMechanics = talentIds.length - ? await ( - await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id, - clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind, - clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds, - outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope, - outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit, - outcomes.sort_order - FROM conditional_effect_rules rules - JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1 - LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id - LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id - WHERE rules.source_type='talent' AND rules.configured=1 - AND rules.source_id IN (${talentIds.map(() => '?').join(',')}) - ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`) - ).all(...talentIds) - : []; - const mechanicsFor = (talentId: unknown) => - talentMechanics.filter( - (mechanic) => String(mechanic.talent_id) === String(talentId), + const uniqueMechanicOwners = [ + ...new Map( + mechanicOwners.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]), + ).values(), + ] as MechanicPublicProjectionOwnerRef[]; + const publicMechanics: MechanicPublicProjectionsByOwner = + uniqueMechanicOwners.length + ? await loadMechanicPublicProjectionsForOwners( + database, + uniqueMechanicOwners, + ) + : ({} as MechanicPublicProjectionsByOwner); + const talentMechanicGroups = (id: unknown) => { + const group = databaseMechanicGroup( + { type: 'talent', id: String(id) }, + publicMechanics, + { label: 'TALENT' }, ); + return group ? [group] : []; + }; + const protocolMechanicGroups = (id: unknown) => { + if (!id) return []; + const group = databaseMechanicGroup( + { type: 'os_protocol_talent', id: String(id) }, + publicMechanics, + { label: 'PROTOCOL TALENT' }, + ); + return group ? [group] : []; + }; + const skillChipMechanicGroups = (id: unknown) => { + if (!id) return []; + const group = databaseMechanicGroup( + { type: 'skill_chip_talent', id: String(id) }, + publicMechanics, + { label: 'SKILL CHIP TALENT' }, + ); + return group ? [group] : []; + }; + const specializationMechanicGroups = (specializationId: unknown) => + specializationMechanicRows.flatMap((row) => { + if (String(row.specialization_id) !== String(specializationId)) return []; + const group = databaseMechanicGroup( + { type: 'specialization_tree_rank', id: String(row.attachment_id) }, + publicMechanics, + { + id: String(row.attachment_id), + label: String(row.combat_effect_name), + context: `${String(row.focus_name)} // ${String(row.node_name)} // RANK ${String(row.rank)} // ROW ${String(row.row_number)}, COLUMN ${String(row.column_number)} // EFFECT ${String(row.effect_order)}`, + }, + ); + return group ? [group] : []; + }); const items: DatabaseItem[] = rows.map((row) => { switch (category.slug) { @@ -737,7 +1028,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { ...relation, icon_updated_at: relation.updated_at, }, - mechanicsFor(relation.id), + talentMechanicGroups(relation.id), ), }; }); @@ -825,9 +1116,13 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { : '', }; }); - const talentItems = (slot: number) => + const talentItems = (slot: number | 'exotic') => relatedTalents - .filter((relation) => Number(relation.talent_slot) === slot) + .filter((relation) => + slot === 'exotic' + ? relation.relation_kind === 'exotic' + : Number(relation.talent_slot) === slot, + ) .map((relation) => { const talentIcon = iconUrl( 'talent', @@ -840,8 +1135,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { iconUrl: talentIcon, preview: talentCardItem( relation, - mechanicsFor(relation.id), - slot, + talentMechanicGroups(relation.id), + typeof slot === 'number' ? slot : undefined, ), }; }); @@ -899,6 +1194,15 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { items: talentItems(2), emptyLabel: 'NO TALENTS ASSIGNED', }, + ...(Number(row.is_exotic) + ? [ + { + label: 'EXOTIC TALENT', + items: talentItems('exotic'), + emptyLabel: 'NO EXOTIC TALENT ASSIGNED', + }, + ] + : []), ], }; } @@ -961,7 +1265,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { }, ], }; - case 'os-protocols': + case 'os-protocols': { + const mechanicGroups = protocolMechanicGroups(row.talent_id); return { id: String(row.id), name: String(row.name), @@ -979,22 +1284,14 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'), primaryUnit: String(row.primary_unit ?? ''), primaryBaseValue: Number(row.base_value), - talentDescription: row.talent_description - ? String(row.talent_description) - : undefined, - talentEffects: protocolTalentEffects - .filter((effect) => effect.protocol_id === row.id) - .map((effect) => ({ - baseValue: Number(effect.base_value), - unit: String(effect.unit ?? ''), - })), + talentDescription: mechanicGroups[0]?.variants[0]?.behaviorText, + talentEffects: [], }, - description: row.talent_description - ? String(row.talent_description) - : undefined, + mechanicGroups, }; + } case 'talents': - return talentCardItem(row, mechanicsFor(row.id)); + return talentCardItem(row, talentMechanicGroups(row.id)); case 'brands': { const effects = brandEffects.filter( (effect) => effect.brand_set_id === row.id, @@ -1020,7 +1317,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { })), }; } - case 'skill-chips': + case 'skill-chips': { + const mechanicGroups = skillChipMechanicGroups(row.talent_id); return { id: String(row.id), name: String(row.name), @@ -1044,10 +1342,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { value: list(row.talent_name, 'NOT CONFIGURED'), }, ], - description: row.talent_description - ? String(row.talent_description) - : undefined, + mechanicGroups, }; + } case 'specializations': return { id: String(row.id), @@ -1070,6 +1367,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => { { label: 'ABILITIES', value: list(row.ability_names) }, ], description: row.description ? String(row.description) : undefined, + mechanicGroups: specializationMechanicGroups(row.id), }; } }) as DatabaseItem[]; diff --git a/src/routes/database/[category]/+page.svelte b/src/routes/database/[category]/+page.svelte index 9c60e36..d808cdf 100644 --- a/src/routes/database/[category]/+page.svelte +++ b/src/routes/database/[category]/+page.svelte @@ -26,8 +26,8 @@
- {#each data.items as item} - + {#each data.items as item (item.id)} + {:else}
◇{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'} diff --git a/src/routes/database/[category]/page.server.test.ts b/src/routes/database/[category]/page.server.test.ts new file mode 100644 index 0000000..7f704b2 --- /dev/null +++ b/src/routes/database/[category]/page.server.test.ts @@ -0,0 +1,674 @@ +import type { + MechanicAuthoredOwner, + MechanicAuthoredSource, +} from '$lib/calculations/mechanic-authored-description'; +import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor'; +import type { DatabaseItem } from '$lib/database/items'; +import type { MechanicPublicProjectionOwnerRef } from '$lib/server/mechanic-public-projection'; +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { saveMechanicAuthoredSourceInTransaction } from '$lib/server/mechanic-authored-sources'; +import { saveMechanicModelRevisionInTransaction } from '$lib/server/mechanic-model-revisions'; +import { refreshMechanicPublicProjectionInTransaction } from '$lib/server/mechanic-public-projection'; + +const mocks = vi.hoisted(() => ({ database: null as Database | null })); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); +vi.mock('$lib/server/game-data-discussions', () => ({ + gameDataDiscussionSummary: vi.fn(async () => new Map()), + gameDataTargetExists: vi.fn(async () => true), +})); +vi.mock('$lib/server/auth/session', () => ({ + getSessionUser: vi.fn(async () => null), +})); +vi.mock('$lib/server/auth/authorization', () => ({ + getPermissions: vi.fn(async () => []), +})); + +const { load } = await import('./+page.server'); + +let database: Database; +const now = '2026-09-03T00:00:00.000Z'; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const execute = async (sql: string, ...parameters: unknown[]) => + (await database.prepare(sql)).run(...parameters); + +function variantKeys(owner: MechanicPublicProjectionOwnerRef) { + switch (owner.type) { + case 'talent': + return ['I', 'II']; + case 'os_protocol_talent': + case 'specialization_tree_rank': + return ['base']; + case 'skill_chip_talent': + return ['minimum', 'maximum']; + } +} + +const authoredOwner = ( + owner: MechanicPublicProjectionOwnerRef, + talentScope: 'standard' | 'exotic' = 'standard', +): MechanicAuthoredOwner => + owner.type === 'talent' ? { ...owner, scope: talentScope } : owner; + +async function createProjection( + owner: MechanicPublicProjectionOwnerRef, + text: string, + options: { + modeled?: boolean; + talentScope?: 'standard' | 'exotic'; + keys?: string[]; + } = {}, +) { + const source: MechanicAuthoredSource = { + version: 1, + owner: authoredOwner(owner, options.talentScope), + template: text, + variants: (options.keys ?? variantKeys(owner)).map((key) => ({ + key, + materialized: text, + slots: [], + })), + ...(owner.type === 'specialization_tree_rank' + ? { + specialization: { + rankDescription: 'Rank effect description.', + effectId: `effect-${owner.id}`, + effectTemplate: text, + }, + } + : {}), + }; + const saved = await saveMechanicAuthoredSourceInTransaction( + database, + source, + { + expectedSourceFingerprint: null, + }, + ); + if (options.modeled) { + const draft: ConditionalMechanicDraftV1 = { + version: 1, + simulationReadiness: 'ready', + scenarioInputs: [], + pools: [], + clauses: [ + { + key: 'mark-target', + label: 'Mark target', + event: 'weapon_hit', + reducer: { kind: 'none' }, + mutations: [], + outcomes: [ + { + key: 'mark', + label: 'Mark', + phase: 'on_event', + kind: 'marker', + scope: 'target', + basis: 'flat', + variants: Object.fromEntries( + variantKeys(owner).map((key) => [key, { value: 1 }]), + ), + }, + ], + }, + ], + }; + await saveMechanicModelRevisionInTransaction(database, saved, draft, { + programName: 'Mark target', + origin: 'test', + actorId: null, + }); + } + await refreshMechanicPublicProjectionInTransaction(database, saved); + return saved; +} + +type CategoryPageData = { + items: DatabaseItem[]; + pagination: { total: number }; +}; + +const loadCategory = async (category: string, query = '') => + (await load({ + params: { category }, + url: new URL(`http://localhost/database/${category}${query}`), + cookies: {}, + } as never)) as unknown as CategoryPageData; + +async function seedPublicOwners() { + await execute( + `INSERT INTO talents(id,name,category,created_at,updated_at) + VALUES('talent-direct','Talent One','Backpack',?,?), + ('talent-weapon','Weapon Talent','Weapon',?,?), + ('talent-corrupt','Corrupt Talent','Backpack',?,?)`, + now, + now, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO gear( + id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max, + range_reference_level,created_at,updated_at + ) VALUES('gear-1','Projection Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-1','talent-direct')`, + ); + await execute( + `INSERT INTO weapons( + id,name,category,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability, + created_at,updated_at + ) VALUES('weapon-1','Projection Rifle','Rifle',0,'Pierce',1,1,1,1,1,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO weapon_tier_ranges( + weapon_id,tier,damage_min,damage_max,firepower_min,firepower_max,toughness_min,toughness_max, + engineering_min,engineering_max,reference_level + ) VALUES('weapon-1',0,10,10,0,0,0,0,0,0,50)`, + ); + await execute( + `INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) + VALUES('weapon-1',1,'talent-weapon')`, + ); + await execute( + `INSERT INTO os_protocols( + id,name,quality,primary_attribute_type,created_at,updated_at + ) VALUES('protocol-1','Projection Protocol','Standard','Firepower',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO os_protocol_talents( + id,protocol_id,name,description_template,created_at,updated_at + ) VALUES('protocol-talent','protocol-1','Protocol Talent','',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specializations(id,name,description,created_at,updated_at) + VALUES('specialization-1','Projection Specialization','Legacy summary.',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_abilities( + id,specialization_id,name,description,created_at,updated_at + ) VALUES('ability-1','specialization-1','Projection Ability','',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO skill_chip_sets( + id,name,specialization_ability_id,created_at,updated_at + ) VALUES('chip-1','Projection Chip','ability-1',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO skill_chip_talents( + id,skill_chip_set_id,name,description_template,created_at,updated_at + ) VALUES('chip-talent','chip-1','Chip Talent','',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES + ('focus-a','specialization-1','Alpha Focus','Firepower',0,?,?), + ('focus-b','specialization-1','Bravo Focus','Toughness',1,?,?)`, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES + ('node-a','focus-a','Alpha Node','',1,1,1,?,?), + ('node-b','focus-b','Bravo Node','',1,1,1,?,?)`, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-a',1,''),('node-b',1,'')`, + ); + await execute( + `INSERT INTO specialization_combat_effects( + id,specialization_id,name,description_template,created_at,updated_at + ) VALUES + ('effect-a1','specialization-1','Alpha First','',?,?), + ('effect-a2','specialization-1','Alpha Second','',?,?), + ('effect-b','specialization-1','Bravo Effect','',?,?)`, + now, + now, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES + ('attachment-a1','node-a',1,'effect-a1',1,'enemy_hits_within_window',1,1,1,1), + ('attachment-a2','node-a',1,'effect-a2',2,'enemy_hits_within_window',1,1,1,1), + ('attachment-b','node-b',1,'effect-b',1,'enemy_hits_within_window',1,1,1,1)`, + ); + + await createProjection( + { type: 'talent', id: 'talent-direct' }, + 'Findable Projection behavior.', + ); + await createProjection( + { type: 'talent', id: 'talent-weapon' }, + 'Weapon Projection behavior.', + ); + const corrupt = await createProjection( + { type: 'talent', id: 'talent-corrupt' }, + 'Corrupt Projection behavior.', + ); + await execute( + `UPDATE mechanic_public_variants + SET behavior_text='Findable Projection forged behavior.' WHERE source_id=?`, + corrupt.id, + ); + await createProjection( + { type: 'os_protocol_talent', id: 'protocol-talent' }, + 'Protocol Projection behavior.', + { modeled: true }, + ); + await createProjection( + { type: 'skill_chip_talent', id: 'chip-talent' }, + 'Chip Projection behavior.', + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-a1' }, + 'Special Projection behavior.', + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-a2' }, + 'Special Projection behavior.', + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-b' }, + 'Special Projection behavior.', + ); +} + +describe('public database category loader', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + mocks.database = database; + await seedPublicOwners(); + }); + + afterEach(async () => { + await database.close(); + mocks.database = null; + }); + + it('maps valid projections into direct cards and every quick-view owner', async () => { + const talents = await loadCategory('talents'); + const direct = talents.items.find((item) => item.id === 'talent-direct'); + const corrupt = talents.items.find((item) => item.id === 'talent-corrupt'); + expect(direct?.mechanicGroups).toMatchObject([ + { + ownerType: 'talent', + variants: [ + { + label: 'TIER I', + behaviorText: 'Findable Projection behavior.', + status: 'not_modeled', + displaySource: 'authored', + }, + { label: 'TIER II', behaviorText: 'Findable Projection behavior.' }, + ], + }, + ]); + expect(direct?.description).toBeUndefined(); + expect(corrupt?.mechanicGroups).toEqual([]); + + const gear = await loadCategory('gear'); + const gearTalent = gear.items[0]?.iconGroups?.[1]?.items[0]?.preview; + expect(gearTalent?.mechanicGroups?.[0]).toMatchObject({ + ownerType: 'talent', + }); + expect(gearTalent?.mechanicGroups?.[0]?.variants[0]).toMatchObject({ + behaviorText: 'Findable Projection behavior.', + }); + + const weapons = await loadCategory('weapons'); + const weaponTalent = weapons.items[0]?.iconGroups?.[0]?.items[0]?.preview; + expect(weaponTalent?.mechanicGroups?.[0]).toMatchObject({ + ownerType: 'talent', + }); + expect(weaponTalent?.mechanicGroups?.[0]?.variants[0]).toMatchObject({ + behaviorText: 'Weapon Projection behavior.', + }); + + const protocols = await loadCategory('os-protocols'); + expect(protocols.items[0]?.mechanicGroups?.[0]).toMatchObject({ + ownerType: 'os_protocol_talent', + variants: [ + expect.objectContaining({ + label: 'BASE', + status: 'modeled', + displaySource: 'model', + }), + ], + }); + expect(protocols.items[0]?.protocolPreview?.talentDescription).toBe( + protocols.items[0]?.mechanicGroups?.[0]?.variants[0]?.behaviorText, + ); + + const chips = await loadCategory('skill-chips'); + expect(chips.items[0]?.mechanicGroups?.[0]).toMatchObject({ + ownerType: 'skill_chip_talent', + variants: [ + { label: 'MINIMUM', behaviorText: 'Chip Projection behavior.' }, + { label: 'MAXIMUM', behaviorText: 'Chip Projection behavior.' }, + ], + }); + expect(chips.items[0]?.description).toBeUndefined(); + }); + + it('maps a Tier I-only Exotic Talent into weapon cards, behavior search, and compatible-weapon filters', async () => { + await execute( + `INSERT INTO talents(id,name,category,created_at,updated_at) + VALUES('talent-exotic','Exotic Weapon Talent','Weapon',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO weapons( + id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability, + exotic_talent_id,created_at,updated_at + ) VALUES('weapon-exotic','Exotic Owner Rifle','Rifle',1,0,'Pierce',1,1,1,1,1,'talent-exotic',?,?)`, + now, + now, + ); + await createProjection( + { type: 'talent', id: 'talent-exotic' }, + 'Exotic Projection behavior.', + { talentScope: 'exotic', keys: ['I'] }, + ); + + const page = await loadCategory('weapons'); + const exoticWeapon = page.items.find((item) => item.id === 'weapon-exotic'); + const exoticTalent = exoticWeapon?.iconGroups?.find( + (group) => group.label === 'EXOTIC TALENT', + )?.items[0]?.preview; + + expect(exoticTalent?.badges).toContainEqual({ + label: 'EXOTIC', + tone: 'exotic', + }); + expect(exoticTalent?.mechanicGroups).toMatchObject([ + { + ownerType: 'talent', + variants: [ + { + label: 'TIER I', + behaviorText: 'Exotic Projection behavior.', + status: 'not_modeled', + displaySource: 'authored', + }, + ], + }, + ]); + expect(exoticTalent?.mechanicGroups?.[0]?.variants).toHaveLength(1); + expect(exoticTalent?.actions).toContainEqual({ + label: 'FIND COMPATIBLE WEAPONS', + href: '/database/weapons?weaponTalent=talent-exotic', + }); + + await expect( + loadCategory('weapons', '?q=Exotic%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'weapon-exotic' }], + }); + await expect( + loadCategory('weapons', '?weaponTalent=talent-exotic'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'weapon-exotic' }], + }); + }); + + it('excludes archived Talent behavior from Gear and Weapon search results', async () => { + await execute( + `INSERT INTO talents(id,name,category,active,created_at,updated_at) + VALUES('talent-archived','Archived Linked Talent','Weapon',0,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO gear( + id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max, + range_reference_level,created_at,updated_at + ) VALUES('gear-archived','Archived Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-archived','talent-archived')`, + ); + await execute( + `INSERT INTO weapons( + id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability, + exotic_talent_id,created_at,updated_at + ) VALUES('weapon-archived','Archived Rifle','Rifle',0,0,'Pierce',1,1,1,1,1,NULL,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) + VALUES('weapon-archived',1,'talent-archived')`, + ); + await createProjection( + { type: 'talent', id: 'talent-archived' }, + 'Archived Linked Projection behavior.', + ); + + await expect( + loadCategory('gear', '?q=Archived%20Linked%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 0 }, + items: [], + }); + await expect( + loadCategory('weapons', '?q=Archived%20Linked%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 0 }, + items: [], + }); + }); + + it('preserves an exact Talent identity and icon URL', async () => { + const talentId = '00000000-0000-4000-8000-000000000003'; + await execute( + `INSERT INTO talents(id,name,category,created_at,updated_at) + VALUES(?, 'Phase 2 Oversight Validated', 'Backpack', ?, ?)`, + talentId, + now, + now, + ); + await execute( + `INSERT INTO game_data_icons(entity_type,entity_id,storage_key,byte_size,updated_at) + VALUES('talent', ?, ?, 1, ?)`, + talentId, + `talent-${talentId}.webp`, + now, + ); + + const page = await loadCategory( + 'talents', + '?q=Phase%202%20Oversight%20Validated', + ); + + expect(page.items).toEqual([ + expect.objectContaining({ + id: talentId, + name: 'Phase 2 Oversight Validated', + iconUrl: `/icons/game-data/talent/${talentId}?v=2026-09-03T00%3A00%3A00.000Z`, + }), + ]); + }); + + it('keeps specialization rank-effect owners independent and ordered', async () => { + const page = await loadCategory('specializations'); + const groups = page.items[0]?.mechanicGroups; + + expect(groups?.map((group) => group.id)).toEqual([ + 'attachment-a1', + 'attachment-a2', + 'attachment-b', + ]); + expect(groups?.map((group) => group.ownerType)).toEqual([ + 'specialization_tree_rank', + 'specialization_tree_rank', + 'specialization_tree_rank', + ]); + expect(groups?.[0]).toMatchObject({ + label: 'Alpha First', + context: + 'Alpha Focus // Alpha Node // RANK 1 // ROW 1, COLUMN 1 // EFFECT 1', + variants: [{ behaviorText: 'Special Projection behavior.' }], + }); + }); + + it('excludes cross-specialization combat effects from category cards and search', async () => { + await execute( + `INSERT INTO specializations(id,name,created_at,updated_at) + VALUES('specialization-cross','Other Specialization',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES('focus-cross','specialization-cross','Cross Focus','Firepower',0,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES('node-cross','focus-cross','Cross Node','',1,1,1,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-cross',1,'')`, + ); + await execute( + `INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES('attachment-cross','node-cross',1,'effect-a1',1, + 'enemy_hits_within_window',1,1,1,1)`, + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-cross' }, + 'Cross-specialization behavior.', + ); + + const page = await loadCategory('specializations'); + expect( + page.items.find((item) => item.id === 'specialization-cross') + ?.mechanicGroups, + ).toEqual([]); + await expect( + loadCategory('specializations', '?q=Cross-specialization'), + ).resolves.toMatchObject({ + pagination: { total: 0 }, + items: [], + }); + }); + + it('uses validated behavior predicates for category count and rows', async () => { + const page = await loadCategory('talents', '?q=Findable%20Projection'); + + expect(page.pagination.total).toBe(1); + expect(page.items).toHaveLength(1); + expect(page.items[0]?.id).toBe('talent-direct'); + expect(page.items[0]?.mechanicGroups).toHaveLength(1); + await expect( + loadCategory('talents', '?q=Findable%20Projection%20forged'), + ).resolves.toMatchObject({ + pagination: { total: 0 }, + items: [], + }); + + await expect( + loadCategory('gear', '?q=Findable%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'gear-1' }], + }); + await expect( + loadCategory('weapons', '?q=Weapon%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'weapon-1' }], + }); + const protocolBehavior = (await loadCategory('os-protocols')).items[0] + ?.mechanicGroups?.[0]?.variants[0]?.behaviorText; + await expect( + loadCategory( + 'os-protocols', + `?q=${encodeURIComponent(protocolBehavior ?? '')}`, + ), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'protocol-1' }], + }); + await expect( + loadCategory('skill-chips', '?q=Chip%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'chip-1' }], + }); + await expect( + loadCategory('specializations', '?q=Special%20Projection'), + ).resolves.toMatchObject({ + pagination: { total: 1 }, + items: [{ id: 'specialization-1' }], + }); + }); +}); diff --git a/src/routes/database/page.server.test.ts b/src/routes/database/page.server.test.ts new file mode 100644 index 0000000..28c705f --- /dev/null +++ b/src/routes/database/page.server.test.ts @@ -0,0 +1,435 @@ +import type { + MechanicAuthoredOwner, + MechanicAuthoredSource, +} from '$lib/calculations/mechanic-authored-description'; +import type { DatabaseMechanicStatus } from '$lib/database/items'; +import type { MechanicPublicProjectionOwnerRef } from '$lib/server/mechanic-public-projection'; +import { connect, type Database } from '@tursodatabase/database'; +import { readFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { saveMechanicAuthoredSourceInTransaction } from '$lib/server/mechanic-authored-sources'; +import { refreshMechanicPublicProjectionInTransaction } from '$lib/server/mechanic-public-projection'; + +const mocks = vi.hoisted(() => ({ database: null as Database | null })); + +vi.mock('$lib/server/database', () => ({ + getDatabase: vi.fn(async () => mocks.database), +})); + +const { load } = await import('./+page.server'); + +let database: Database; +const now = '2026-09-03T00:00:00.000Z'; + +const applySchema = async (target: Database) => { + const schema = await readFile( + new URL('../../../database/schema.sql', import.meta.url), + 'utf8', + ); + for (const statement of schema + .split(';') + .map((value) => value.trim()) + .filter(Boolean)) { + await target.exec(statement); + } +}; + +const execute = async (sql: string, ...parameters: unknown[]) => + (await database.prepare(sql)).run(...parameters); + +function variantKeys(owner: MechanicPublicProjectionOwnerRef) { + switch (owner.type) { + case 'talent': + return ['I', 'II']; + case 'os_protocol_talent': + case 'specialization_tree_rank': + return ['base']; + case 'skill_chip_talent': + return ['minimum', 'maximum']; + } +} + +const authoredOwner = ( + owner: MechanicPublicProjectionOwnerRef, + talentScope: 'standard' | 'exotic' = 'standard', +): MechanicAuthoredOwner => + owner.type === 'talent' ? { ...owner, scope: talentScope } : owner; + +async function createProjection( + owner: MechanicPublicProjectionOwnerRef, + text: string, + options: { + talentScope?: 'standard' | 'exotic'; + keys?: string[]; + } = {}, +) { + const source: MechanicAuthoredSource = { + version: 1, + owner: authoredOwner(owner, options.talentScope), + template: text, + variants: (options.keys ?? variantKeys(owner)).map((key) => ({ + key, + materialized: text, + slots: [], + })), + ...(owner.type === 'specialization_tree_rank' + ? { + specialization: { + rankDescription: 'Rank description.', + effectId: `effect-${owner.id}`, + effectTemplate: text, + }, + } + : {}), + }; + const saved = await saveMechanicAuthoredSourceInTransaction( + database, + source, + { + expectedSourceFingerprint: null, + }, + ); + await refreshMechanicPublicProjectionInTransaction(database, saved); + return saved; +} + +type GlobalSearchResult = { + slug: string; + category: string; + name: string; + detail: string; + behaviorText?: string; + status?: DatabaseMechanicStatus; +}; + +const loadGlobal = async (query: string) => + (await load({ + url: new URL(`http://localhost/database${query}`), + } as never)) as unknown as { searchResults: GlobalSearchResult[] }; + +async function seedSearchData() { + await execute( + `INSERT INTO talents(id,name,category,created_at,updated_at) VALUES + ('talent-valid','Legacy Talent Name','Backpack',?,?), + ('talent-corrupt','Corrupt Talent','Backpack',?,?), + ('talent-name-only','Existing Name Search','Weapon',?,?), + ('talent-exotic','Exotic Weapon Talent','Weapon',?,?)`, + now, + now, + now, + now, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO talents(id,name,category,active,created_at,updated_at) + VALUES('talent-archived','Archived Linked Talent','Weapon',0,?,?)`, + now, + now, + ); + const corrupt = await createProjection( + { type: 'talent', id: 'talent-corrupt' }, + 'Corrupt Projection behavior.', + ); + await execute( + `UPDATE mechanic_public_variants + SET behavior_text='Findable Projection forged behavior.' WHERE source_id=?`, + corrupt.id, + ); + await createProjection( + { type: 'talent', id: 'talent-valid' }, + 'Findable Projection behavior.', + ); + await createProjection( + { type: 'talent', id: 'talent-exotic' }, + 'Exotic Projection behavior.', + { talentScope: 'exotic', keys: ['I'] }, + ); + await createProjection( + { type: 'talent', id: 'talent-archived' }, + 'Archived Linked Projection behavior.', + ); + await execute( + `INSERT INTO gear( + id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max, + range_reference_level,created_at,updated_at + ) VALUES('gear-archived','Archived Projection Backpack','Backpack',0,10,10,NULL,NULL,50,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO gear_talents(gear_id,talent_id) VALUES('gear-archived','talent-archived')`, + ); + await execute( + `INSERT INTO weapons( + id,name,category,is_exotic,native_tier,damage_type,rpm,magazine,reload_speed,accuracy,stability, + exotic_talent_id,created_at,updated_at + ) VALUES + ('weapon-exotic','Exotic Projection Rifle','Rifle',1,0,'Pierce',1,1,1,1,1,'talent-exotic',?,?), + ('weapon-archived','Archived Projection Rifle','Rifle',0,0,'Pierce',1,1,1,1,1,NULL,?,?)`, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO weapon_talent_options(weapon_id,talent_slot,talent_id) + VALUES('weapon-archived',1,'talent-archived')`, + ); + + await execute( + `INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) + VALUES('protocol-1','Protocol','Standard','Firepower',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) + VALUES('protocol-talent','protocol-1','Protocol Talent','',?,?)`, + now, + now, + ); + await createProjection( + { type: 'os_protocol_talent', id: 'protocol-talent' }, + 'Protocol Projection behavior.', + ); + + await execute( + `INSERT INTO specializations(id,name,created_at,updated_at) + VALUES('specialization-1','Specialization',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_abilities(id,specialization_id,name,description,created_at,updated_at) + VALUES('ability-1','specialization-1','Ability','',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO skill_chip_sets(id,name,specialization_ability_id,created_at,updated_at) + VALUES('chip-1','Chip','ability-1',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO skill_chip_talents(id,skill_chip_set_id,name,description_template,created_at,updated_at) + VALUES('chip-talent','chip-1','Chip Talent','',?,?)`, + now, + now, + ); + await createProjection( + { type: 'skill_chip_talent', id: 'chip-talent' }, + 'Chip Projection behavior.', + ); + + await execute( + `INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES('focus-1','specialization-1','Focus','Firepower',0,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES('node-1','focus-1','Node','',1,1,1,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-1',1,'')`, + ); + await execute( + `INSERT INTO specialization_combat_effects( + id,specialization_id,name,description_template,created_at,updated_at + ) VALUES + ('effect-1','specialization-1','Effect One','',?,?), + ('effect-2','specialization-1','Effect Two','',?,?)`, + now, + now, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES + ('attachment-1','node-1',1,'effect-1',1,'enemy_hits_within_window',1,1,1,1), + ('attachment-2','node-1',1,'effect-2',2,'enemy_hits_within_window',1,1,1,1)`, + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-1' }, + 'Special Projection behavior.', + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-2' }, + 'Special Projection behavior.', + ); +} + +describe('public database global search loader', () => { + beforeEach(async () => { + database = await connect(':memory:'); + await database.exec('PRAGMA foreign_keys=ON'); + await applySchema(database); + mocks.database = database; + await seedSearchData(); + }); + + afterEach(async () => { + await database.close(); + mocks.database = null; + }); + + it('searches only canonical behavior and excludes corrupt cache text', async () => { + const page = await loadGlobal('?q=Findable%20Projection'); + const valid = page.searchResults.find( + (result) => result.name === 'Legacy Talent Name', + ); + + expect(valid).toMatchObject({ + behaviorText: 'Findable Projection behavior.', + status: 'not_modeled', + }); + expect(page.searchResults).not.toContainEqual( + expect.objectContaining({ name: 'Corrupt Talent' }), + ); + await expect( + loadGlobal('?q=Findable%20Projection%20forged'), + ).resolves.toMatchObject({ searchResults: [] }); + }); + + it('finds an Exotic weapon through its Tier I-only Talent projection', async () => { + const page = await loadGlobal('?q=Exotic%20Projection'); + + expect(page.searchResults).toContainEqual( + expect.objectContaining({ + slug: 'weapons', + name: 'Exotic Projection Rifle', + behaviorText: 'Exotic Projection behavior.', + status: 'not_modeled', + }), + ); + }); + + it('excludes archived Talent projections from linked Gear and Weapon results', async () => { + await expect(loadGlobal('?q=Archived%20Linked%20Projection')).resolves.toMatchObject({ + searchResults: [], + }); + }); + + it('excludes cross-specialization combat effects from global behavior search', async () => { + await execute( + `INSERT INTO specializations(id,name,created_at,updated_at) + VALUES('specialization-cross','Other Specialization',?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_focuses( + id,specialization_id,name,primary_attribute,display_order,created_at,updated_at + ) VALUES('focus-cross','specialization-cross','Cross Focus','Firepower',0,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_nodes( + id,focus_id,name,description_template,row_number,column_number,max_rank,created_at,updated_at + ) VALUES('node-cross','focus-cross','Cross Node','',1,1,1,?,?)`, + now, + now, + ); + await execute( + `INSERT INTO specialization_tree_node_ranks(node_id,rank,description) + VALUES('node-cross',1,'')`, + ); + await execute( + `INSERT INTO specialization_tree_rank_combat_effects( + id,node_id,rank,combat_effect_id,effect_order,trigger_type,trigger_count, + trigger_window_seconds,duration_seconds,max_stacks + ) VALUES('attachment-cross','node-cross',1,'effect-1',1, + 'enemy_hits_within_window',1,1,1,1)`, + ); + await createProjection( + { type: 'specialization_tree_rank', id: 'attachment-cross' }, + 'Cross-specialization behavior.', + ); + + await expect(loadGlobal('?q=Cross-specialization')).resolves.toMatchObject({ + searchResults: [], + }); + }); + + it('preserves exact Talent identity in global search results', async () => { + await execute( + `INSERT INTO talents(id,name,category,created_at,updated_at) + VALUES('talent-identity','Phase 2 Oversight Validated','Backpack',?,?)`, + now, + now, + ); + + await expect( + loadGlobal('?q=Phase%202%20Oversight%20Validated'), + ).resolves.toMatchObject({ + searchResults: [ + expect.objectContaining({ + slug: 'talents', + name: 'Phase 2 Oversight Validated', + }), + ], + }); + }); + + it('deduplicates matching specialization attachments to one parent result', async () => { + const page = await loadGlobal('?q=Special%20Projection'); + const results = page.searchResults.filter( + (result) => result.slug === 'specializations', + ); + + expect(results).toEqual([ + expect.objectContaining({ + name: 'Specialization', + behaviorText: 'Special Projection behavior.', + status: 'not_modeled', + }), + ]); + }); + + it('finds projected protocol and chip behavior and preserves name search', async () => { + await expect(loadGlobal('?q=Protocol%20Projection')).resolves.toMatchObject( + { + searchResults: [ + expect.objectContaining({ + slug: 'os-protocols', + behaviorText: 'Protocol Projection behavior.', + status: 'not_modeled', + }), + ], + }, + ); + await expect(loadGlobal('?q=Chip%20Projection')).resolves.toMatchObject({ + searchResults: [ + expect.objectContaining({ + slug: 'skill-chips', + behaviorText: 'Chip Projection behavior.', + status: 'not_modeled', + }), + ], + }); + await expect( + loadGlobal('?q=Existing%20Name%20Search'), + ).resolves.toMatchObject({ + searchResults: [ + expect.objectContaining({ + slug: 'talents', + name: 'Existing Name Search', + }), + ], + }); + }); +}); diff --git a/src/routes/database/public-mechanics.ts b/src/routes/database/public-mechanics.ts new file mode 100644 index 0000000..7ab616a --- /dev/null +++ b/src/routes/database/public-mechanics.ts @@ -0,0 +1,73 @@ +import type { + DatabaseMechanicGroup, + DatabaseMechanicStatus, +} from '$lib/database/items'; +import { + mechanicPublicProjectionOwnerKey, + type MechanicPublicProjectionOwnerRef, + type MechanicPublicProjectionsByOwner, +} from '$lib/server/mechanic-public-projection'; + +const variantLabel = (key: string) => { + switch (key) { + case 'I': + return 'TIER I'; + case 'II': + return 'TIER II'; + case 'minimum': + return 'MINIMUM'; + case 'maximum': + return 'MAXIMUM'; + case 'base': + return 'BASE'; + default: + return key.toUpperCase(); + } +}; + +/** Converts only a validated public projection into a card-safe group. */ +export function databaseMechanicGroup( + owner: MechanicPublicProjectionOwnerRef, + projections: MechanicPublicProjectionsByOwner, + options: { id?: string; label: string; context?: string }, +): DatabaseMechanicGroup | null { + const variants = projections[mechanicPublicProjectionOwnerKey(owner)] ?? []; + if (!variants.length) return null; + + return { + id: options.id ?? owner.id, + ownerType: owner.type, + label: options.label, + ...(options.context ? { context: options.context } : {}), + variants: variants.map((variant) => ({ + label: variantLabel(variant.variantKey), + behaviorText: variant.behaviorText, + status: variant.status, + displaySource: variant.displaySource, + })), + }; +} + +/** Matches only behavior returned by the canonical validated projection loader. */ +export function databaseMechanicOwnersMatchSearch( + owners: MechanicPublicProjectionOwnerRef[], + projections: MechanicPublicProjectionsByOwner, + search: string, +) { + const normalizedSearch = search.toLocaleLowerCase(); + return owners.some((owner) => + (projections[mechanicPublicProjectionOwnerKey(owner)] ?? []).some( + (variant) => + variant.behaviorText.toLocaleLowerCase().includes(normalizedSearch), + ), + ); +} + +export function databaseMechanicSearchSummary( + group: DatabaseMechanicGroup | null, +): { behaviorText: string; status: DatabaseMechanicStatus } | null { + const first = group?.variants[0]; + return first + ? { behaviorText: first.behaviorText, status: first.status } + : null; +}