feat: consume selected mechanic revisions
This commit is contained in:
@@ -1857,6 +1857,34 @@ responsive card grid. Record cards use the same hierarchy:
|
|||||||
- bordered fact cells for primary values;
|
- bordered fact cells for primary values;
|
||||||
- relationship lines and tier descriptions only when applicable.
|
- 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,
|
When a record represents an ordered progression rather than independent facts,
|
||||||
use the stacked fact treatment. Brand Set bonuses are shown vertically in two-,
|
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
|
three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when
|
||||||
|
|||||||
+232
@@ -19404,3 +19404,235 @@ a.secondary:active {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,12 +10,36 @@ import {
|
|||||||
equippedConditionalProgram,
|
equippedConditionalProgram,
|
||||||
mechanicModifiersAt,
|
mechanicModifiersAt,
|
||||||
skillModifierWindowsForMechanic,
|
skillModifierWindowsForMechanic,
|
||||||
|
evaluateLoadoutCombat,
|
||||||
weaponCriticalChance,
|
weaponCriticalChance,
|
||||||
} from './loadout-combat.js';
|
} from './loadout-combat.js';
|
||||||
import {
|
import {
|
||||||
calculateSkillDps,
|
calculateSkillDps,
|
||||||
type SkillAbilityInput,
|
type SkillAbilityInput,
|
||||||
} from '$lib/calculations/skill';
|
} from '$lib/calculations/skill';
|
||||||
|
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||||
|
|
||||||
|
function selectedRuntime(
|
||||||
|
variants: Record<string, MechanicProgram>,
|
||||||
|
options: Partial<
|
||||||
|
Pick<MechanicRuntimeSelection, 'simulationReadiness' | 'scenarioInputs'>
|
||||||
|
> = {},
|
||||||
|
): 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', () => {
|
describe('loadout mechanic timeline', () => {
|
||||||
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
|
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
|
||||||
@@ -291,82 +315,568 @@ describe('loadout mechanic timeline', () => {
|
|||||||
).toEqual([1, 2]);
|
).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
|
it('selects a direct validated Phase 2 program without adapting legacy rows', () => {
|
||||||
const persistedRule = {
|
const tierTwo: MechanicProgram = {
|
||||||
rule: {
|
id: 'mechanic-runtime:["source","II"]',
|
||||||
id: 'tier-i-only',
|
name: 'Selected Tier II',
|
||||||
source_type: 'talent',
|
clauses: [],
|
||||||
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: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const adapted = equippedConditionalProgram({
|
expect(
|
||||||
key: 'weapon:0:exotic-talent',
|
equippedConditionalProgram({
|
||||||
tier: 2,
|
key: 'weapon:0:talent',
|
||||||
isExotic: true,
|
tier: 2,
|
||||||
rule: persistedRule,
|
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({
|
const interpolated = equippedConditionalProgram({
|
||||||
id: 'weapon-damage-i',
|
key: 'chips:chip-set:ability',
|
||||||
value: 15,
|
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<string, number | boolean>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,8 @@ import {
|
|||||||
type MechanicEvent,
|
type MechanicEvent,
|
||||||
type MechanicOutcome,
|
type MechanicOutcome,
|
||||||
type MechanicProgram,
|
type MechanicProgram,
|
||||||
type NumericExpression,
|
|
||||||
} from '$lib/calculations/mechanic';
|
} from '$lib/calculations/mechanic';
|
||||||
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
|
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||||
import {
|
import {
|
||||||
calculateSkillDps,
|
calculateSkillDps,
|
||||||
skillActivationTimes,
|
skillActivationTimes,
|
||||||
@@ -192,103 +191,157 @@ export function weaponCriticalChance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function conditionalProgram(
|
function selectedRuntimeProgram(
|
||||||
rule: any,
|
runtime: MechanicRuntimeSelection | null | undefined,
|
||||||
variantKey: string,
|
variantKey: string,
|
||||||
valueScale = 1,
|
|
||||||
): MechanicProgram | null {
|
): MechanicProgram | null {
|
||||||
if (!rule?.rule) return null;
|
return runtime?.variants.find((variant) => variant.key === variantKey)?.program ?? 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),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function interpolatedConditionalProgram(
|
function hasMechanicProgram<T extends { program: MechanicProgram | null }>(
|
||||||
rule: any,
|
source: T,
|
||||||
|
): source is T & { program: MechanicProgram } {
|
||||||
|
return source.program !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InterpolatedMechanicValue = { value: unknown };
|
||||||
|
|
||||||
|
function isMechanicDefinitionRecord(
|
||||||
|
value: unknown,
|
||||||
|
): value is Record<string, unknown> {
|
||||||
|
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,
|
minimumVariant: string,
|
||||||
maximumVariant: string,
|
maximumVariant: string,
|
||||||
step: number,
|
step: number,
|
||||||
totalSteps: number,
|
totalSteps: number,
|
||||||
): MechanicProgram | null {
|
): MechanicProgram | null {
|
||||||
const minimum = conditionalProgram(rule, minimumVariant);
|
const minimum = selectedRuntimeProgram(runtime, minimumVariant);
|
||||||
const maximum = conditionalProgram(rule, maximumVariant);
|
const maximum = selectedRuntimeProgram(runtime, maximumVariant);
|
||||||
if (!minimum || !maximum) return minimum ?? maximum;
|
if (!minimum || !maximum) return null;
|
||||||
const ratio = Math.max(
|
const ratio = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(1, Number(step) / Math.max(1, Number(totalSteps))),
|
Math.min(1, Number(step) / Math.max(1, Number(totalSteps))),
|
||||||
);
|
);
|
||||||
const maximumByKey = new Map(
|
if (!Number.isFinite(ratio)) return null;
|
||||||
maximum.clauses.flatMap((clause) =>
|
const interpolated = interpolateMechanicProgram(minimum, maximum, ratio);
|
||||||
(clause.outcomes ?? []).map(
|
if (!interpolated) return null;
|
||||||
(outcome) => [`${clause.id}:${outcome.id}`, outcome] as const,
|
if (ratio === 0) return minimum;
|
||||||
),
|
if (ratio === 1) return maximum;
|
||||||
),
|
return interpolated;
|
||||||
);
|
|
||||||
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;
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function equippedConditionalProgram(
|
export function equippedConditionalProgram(
|
||||||
equipped: any,
|
equipped: Pick<
|
||||||
|
LoadoutSummary['equippedTalents'][number],
|
||||||
|
'key' | 'runtime' | 'tier' | 'isExotic'
|
||||||
|
>,
|
||||||
): MechanicProgram | null {
|
): MechanicProgram | null {
|
||||||
if (String(equipped.key).startsWith('chips:')) {
|
if (equipped.key.startsWith('chips:')) {
|
||||||
return interpolatedConditionalProgram(
|
return interpolatedRuntimeProgram(
|
||||||
equipped.rule,
|
equipped.runtime,
|
||||||
'minimum',
|
'minimum',
|
||||||
'maximum',
|
'maximum',
|
||||||
Number(equipped.tier),
|
Number(equipped.tier),
|
||||||
3,
|
3,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (String(equipped.key).startsWith('protocol:')) {
|
if (equipped.key.startsWith('protocol:')) {
|
||||||
return conditionalProgram(equipped.rule, 'base');
|
return selectedRuntimeProgram(equipped.runtime, 'base');
|
||||||
}
|
}
|
||||||
const equipmentTalent =
|
const equipmentTalent =
|
||||||
String(equipped.key).startsWith('weapon:') ||
|
equipped.key.startsWith('weapon:') || equipped.key.startsWith('gear:');
|
||||||
String(equipped.key).startsWith('gear:');
|
const variant = equipmentTalent && Number(equipped.tier) >= 2 ? 'II' : 'I';
|
||||||
const variant = equipmentTalent
|
const selected = selectedRuntimeProgram(equipped.runtime, variant);
|
||||||
? Number(equipped.tier) >= 2
|
return selected ??
|
||||||
? 'II'
|
|
||||||
: 'I'
|
|
||||||
: 'base';
|
|
||||||
return (
|
|
||||||
conditionalProgram(equipped.rule, variant) ??
|
|
||||||
(equipmentTalent && equipped.isExotic && variant === 'II'
|
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||||
? conditionalProgram(equipped.rule, 'I')
|
? selectedRuntimeProgram(equipped.runtime, 'I')
|
||||||
: null)
|
: null);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ActiveMechanicEvaluation = {
|
export type ActiveMechanicEvaluation = {
|
||||||
@@ -299,15 +352,58 @@ export type ActiveMechanicEvaluation = {
|
|||||||
events?: MechanicEvent[];
|
events?: MechanicEvent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function configuredScenarioInputValues(configured: unknown) {
|
||||||
|
return configured && typeof configured === 'object' && !Array.isArray(configured)
|
||||||
|
? (configured as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<MechanicProgram['scenarioInputs']>[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(
|
function mechanicScenarioInputs(
|
||||||
program: MechanicProgram,
|
program: MechanicProgram,
|
||||||
configured: unknown,
|
configured: unknown,
|
||||||
maximum = false,
|
maximum = false,
|
||||||
) {
|
): Record<string, number | boolean> {
|
||||||
const values =
|
const values = configuredScenarioInputValues(configured) as Record<
|
||||||
configured && typeof configured === 'object' && !Array.isArray(configured)
|
string,
|
||||||
? (configured as Record<string, number | boolean>)
|
number | boolean
|
||||||
: {};
|
>;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
(program.scenarioInputs ?? []).map((input) => [
|
(program.scenarioInputs ?? []).map((input) => [
|
||||||
input.key,
|
input.key,
|
||||||
@@ -322,22 +418,59 @@ function mechanicScenarioInputs(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mechanicIsEnabled(
|
||||||
|
state: Pick<LoadoutDraft['conditionalEffects'][string], 'mode'>,
|
||||||
|
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(
|
function evaluateConfiguredMechanic(
|
||||||
program: MechanicProgram,
|
program: MechanicProgram,
|
||||||
buildEvents: (
|
buildEvents: (
|
||||||
scenarioInputs: Record<string, number | boolean>,
|
scenarioInputs: Record<string, number | boolean>,
|
||||||
) => MechanicEvent[],
|
) => MechanicEvent[],
|
||||||
state: any,
|
state: LoadoutDraft['conditionalEffects'][string],
|
||||||
profile: LoadoutDraft['combatProfile'],
|
profile: LoadoutDraft['combatProfile'],
|
||||||
weaponBaseDamage: number,
|
weaponBaseDamage: number,
|
||||||
maxHealth: number,
|
maxHealth: number,
|
||||||
calculationMode: LoadoutDraft['calculationMode'],
|
calculationMode: LoadoutDraft['calculationMode'],
|
||||||
equipmentContext = { protocolTalentUpgrades: 0 },
|
equipmentContext = { protocolTalentUpgrades: 0 },
|
||||||
attributes: Record<string, number> = {},
|
attributes: Record<string, number> = {},
|
||||||
|
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'] = 'ready',
|
||||||
): ActiveMechanicEvaluation | null {
|
): 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 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 scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
|
||||||
const events = buildEvents(scenarioInputs);
|
const events = buildEvents(scenarioInputs);
|
||||||
const result = evaluateMechanicProgram(
|
const result = evaluateMechanicProgram(
|
||||||
@@ -1045,7 +1178,7 @@ function skillModifierWindows(
|
|||||||
catalog: Catalog,
|
catalog: Catalog,
|
||||||
summary: LoadoutSummary,
|
summary: LoadoutSummary,
|
||||||
allowedAttributeIds: Set<string>,
|
allowedAttributeIds: Set<string>,
|
||||||
): SkillModifierWindow[] {
|
): { windows: SkillModifierWindow[]; unavailable: string[] } {
|
||||||
const focus = selectedFocus(draft, catalog);
|
const focus = selectedFocus(draft, catalog);
|
||||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(
|
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(
|
||||||
({ node, rank }: any) =>
|
({ node, rank }: any) =>
|
||||||
@@ -1058,21 +1191,25 @@ function skillModifierWindows(
|
|||||||
const sources = [
|
const sources = [
|
||||||
...summary.equippedTalents.map((equipped: any) => ({
|
...summary.equippedTalents.map((equipped: any) => ({
|
||||||
key: String(equipped.key),
|
key: String(equipped.key),
|
||||||
|
name: String(equipped.custom?.name ?? 'Talent'),
|
||||||
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
|
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
|
||||||
equipmentContext: {
|
equipmentContext: {
|
||||||
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
|
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
|
||||||
? Number(equipped.tier)
|
? Number(equipped.tier)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
|
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
|
||||||
program: equippedConditionalProgram(equipped),
|
program: equippedConditionalProgram(equipped),
|
||||||
})),
|
})),
|
||||||
...combatEffects.map((effect: any) => ({
|
...combatEffects.map((effect: any) => ({
|
||||||
key: String(effect.id),
|
key: String(effect.id),
|
||||||
|
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
|
||||||
abilityId: effect.abilityId,
|
abilityId: effect.abilityId,
|
||||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
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 primarySelection = draft.weapons[0];
|
||||||
const primaryWeapon = catalog.weapons.find(
|
const primaryWeapon = catalog.weapons.find(
|
||||||
(weapon) => weapon.id === primarySelection.itemId,
|
(weapon) => weapon.id === primarySelection.itemId,
|
||||||
@@ -1123,6 +1260,7 @@ function skillModifierWindows(
|
|||||||
draft.calculationMode,
|
draft.calculationMode,
|
||||||
source.equipmentContext,
|
source.equipmentContext,
|
||||||
baseAttributes,
|
baseAttributes,
|
||||||
|
source.simulationReadiness,
|
||||||
);
|
);
|
||||||
return evaluation ? [{ ...source, evaluation }] : [];
|
return evaluation ? [{ ...source, evaluation }] : [];
|
||||||
});
|
});
|
||||||
@@ -1138,7 +1276,10 @@ function skillModifierWindows(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return windows;
|
return {
|
||||||
|
windows,
|
||||||
|
unavailable: unavailableMechanicEvidence(sources, draft),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function evaluateLoadoutCombat(
|
export function evaluateLoadoutCombat(
|
||||||
@@ -1166,6 +1307,7 @@ export function evaluateLoadoutCombat(
|
|||||||
? Number(equipped.tier)
|
? Number(equipped.tier)
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
|
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
|
||||||
program: equippedConditionalProgram(equipped),
|
program: equippedConditionalProgram(equipped),
|
||||||
})),
|
})),
|
||||||
...combatEffects.map((effect: any) => ({
|
...combatEffects.map((effect: any) => ({
|
||||||
@@ -1174,9 +1316,10 @@ export function evaluateLoadoutCombat(
|
|||||||
effect.definition?.name ?? effect.nodeName ?? 'Specialization effect',
|
effect.definition?.name ?? effect.nodeName ?? 'Specialization effect',
|
||||||
),
|
),
|
||||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
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(
|
const skillDeploymentEvents = loadoutSkillDeploymentEvents(
|
||||||
draft,
|
draft,
|
||||||
catalog,
|
catalog,
|
||||||
@@ -1201,6 +1344,7 @@ export function evaluateLoadoutCombat(
|
|||||||
missing.push(
|
missing.push(
|
||||||
`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`,
|
`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`,
|
||||||
);
|
);
|
||||||
|
missing.push(...unavailableMechanicEvidence(conditionalSources, draft));
|
||||||
return missing;
|
return missing;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1251,6 +1395,7 @@ export function evaluateLoadoutCombat(
|
|||||||
draft.calculationMode,
|
draft.calculationMode,
|
||||||
source.equipmentContext,
|
source.equipmentContext,
|
||||||
criticalAttributes,
|
criticalAttributes,
|
||||||
|
source.simulationReadiness,
|
||||||
);
|
);
|
||||||
return evaluation ? [{ ...source, state, evaluation }] : [];
|
return evaluation ? [{ ...source, state, evaluation }] : [];
|
||||||
});
|
});
|
||||||
@@ -1418,6 +1563,21 @@ export function evaluateLoadoutSkillCombat(
|
|||||||
.map((stat) => [stat.id, stat.value]),
|
.map((stat) => [stat.id, stat.value]),
|
||||||
);
|
);
|
||||||
const abilities = loadoutSkillAbilities(draft, catalog);
|
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({
|
return calculateSkillDps({
|
||||||
rules: { revisionId, bindings },
|
rules: { revisionId, bindings },
|
||||||
abilities,
|
abilities,
|
||||||
@@ -1440,7 +1600,7 @@ export function evaluateLoadoutSkillCombat(
|
|||||||
];
|
];
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
|
modifierWindows: mechanicWindows.windows,
|
||||||
assumptions: {
|
assumptions: {
|
||||||
encounterSeconds: draft.combatProfile.encounterSeconds,
|
encounterSeconds: draft.combatProfile.encounterSeconds,
|
||||||
targetCount: Math.max(
|
targetCount: Math.max(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||||
import { createDraft } from './loadout.js';
|
import { createDraft } from './loadout.js';
|
||||||
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||||
|
|
||||||
@@ -35,6 +36,24 @@ const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
|||||||
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||||
summary.stats.find((entry) => entry.id === id);
|
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', () => {
|
describe('summarizeLoadout', () => {
|
||||||
it('returns an empty deterministic summary for a partial draft', () => {
|
it('returns an empty deterministic summary for a partial draft', () => {
|
||||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||||
@@ -193,7 +212,6 @@ describe('summarizeLoadout', () => {
|
|||||||
max_value: 45,
|
max_value: 45,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rule: null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -399,7 +417,6 @@ describe('summarizeLoadout', () => {
|
|||||||
name: 'Recovery',
|
name: 'Recovery',
|
||||||
description_template: 'Restore {1}.',
|
description_template: 'Restore {1}.',
|
||||||
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||||
rule: null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -536,19 +553,6 @@ describe('summarizeLoadout', () => {
|
|||||||
exoticTalentTier: 1,
|
exoticTalentTier: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const rule = {
|
|
||||||
clauses: [
|
|
||||||
{
|
|
||||||
outcomes: [
|
|
||||||
{
|
|
||||||
kind: 'attribute_modifier',
|
|
||||||
attributeId: 'talent-stat',
|
|
||||||
value: 100,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const summary = summarizeLoadout(
|
const summary = summarizeLoadout(
|
||||||
draft,
|
draft,
|
||||||
catalog({
|
catalog({
|
||||||
@@ -585,7 +589,6 @@ describe('summarizeLoadout', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
rule,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'talent-two',
|
id: 'talent-two',
|
||||||
@@ -627,7 +630,7 @@ describe('summarizeLoadout', () => {
|
|||||||
tier: 1,
|
tier: 1,
|
||||||
tierLabel: 'TIER I',
|
tierLabel: 'TIER I',
|
||||||
description: 'Gain 10% for 2 Shots.',
|
description: 'Gain 10% for 2 Shots.',
|
||||||
rule,
|
runtime: null,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'talent-two',
|
id: 'talent-two',
|
||||||
@@ -645,6 +648,69 @@ describe('summarizeLoadout', () => {
|
|||||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
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', () => {
|
it('uses the authored tier description when a Talent has no template row', () => {
|
||||||
const draft = createDraft();
|
const draft = createDraft();
|
||||||
draft.weapons[0].itemId = 'weapon';
|
draft.weapons[0].itemId = 'weapon';
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type LoadoutDraft,
|
type LoadoutDraft,
|
||||||
type TalentTier,
|
type TalentTier,
|
||||||
} from '$lib/builder/loadout';
|
} from '$lib/builder/loadout';
|
||||||
|
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||||
|
|
||||||
export type LoadoutCatalog = {
|
export type LoadoutCatalog = {
|
||||||
attributes: any[];
|
attributes: any[];
|
||||||
@@ -38,7 +39,18 @@ export type LoadoutSummary = {
|
|||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
activeBonuses: any[];
|
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;
|
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||||
@@ -164,7 +176,7 @@ export function summarizeLoadout(
|
|||||||
)?.id ?? '';
|
)?.id ?? '';
|
||||||
const brandCounts = new Map<string, number>();
|
const brandCounts = new Map<string, number>();
|
||||||
const activeBonuses: any[] = [];
|
const activeBonuses: any[] = [];
|
||||||
const equippedTalents: any[] = [];
|
const equippedTalents: LoadoutSummary['equippedTalents'] = [];
|
||||||
const abilityValues = new Map<string, Map<string, number>>();
|
const abilityValues = new Map<string, Map<string, number>>();
|
||||||
const abilitySources = new Map<
|
const abilitySources = new Map<
|
||||||
string,
|
string,
|
||||||
@@ -260,9 +272,9 @@ export function summarizeLoadout(
|
|||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `gear:${slot}:${selection.talentId}`,
|
key: `gear:${slot}:${selection.talentId}`,
|
||||||
id: selection.talentId,
|
id: selection.talentId,
|
||||||
rule:
|
runtime:
|
||||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||||
?.rule ?? null,
|
?.runtime ?? null,
|
||||||
tier: selection.talentTier,
|
tier: selection.talentTier,
|
||||||
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||||
isExotic: false,
|
isExotic: false,
|
||||||
@@ -336,9 +348,9 @@ export function summarizeLoadout(
|
|||||||
equippedTalents.push({
|
equippedTalents.push({
|
||||||
key: `weapon:${index}:${equippedTalent.id}`,
|
key: `weapon:${index}:${equippedTalent.id}`,
|
||||||
id: equippedTalent.id,
|
id: equippedTalent.id,
|
||||||
rule:
|
runtime:
|
||||||
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||||
?.rule ?? null,
|
?.runtime ?? null,
|
||||||
tier: equippedTalent.tier,
|
tier: equippedTalent.tier,
|
||||||
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||||
isExotic: equippedTalent.isExotic,
|
isExotic: equippedTalent.isExotic,
|
||||||
@@ -434,7 +446,7 @@ export function summarizeLoadout(
|
|||||||
key: `chips:${group.item.id}:${group.abilityId}`,
|
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||||
id: '',
|
id: '',
|
||||||
custom: group.item.talent,
|
custom: group.item.talent,
|
||||||
rule: group.item.talent.rule ?? null,
|
runtime: group.item.talent.runtime ?? null,
|
||||||
tier: group.highEnd,
|
tier: group.highEnd,
|
||||||
abilityId: group.abilityId || null,
|
abilityId: group.abilityId || null,
|
||||||
source: '3 Skill Chips',
|
source: '3 Skill Chips',
|
||||||
@@ -486,7 +498,7 @@ export function summarizeLoadout(
|
|||||||
key: `protocol:${protocol.id}`,
|
key: `protocol:${protocol.id}`,
|
||||||
id: '',
|
id: '',
|
||||||
custom: protocol.talent,
|
custom: protocol.talent,
|
||||||
rule: protocol.talent.rule ?? null,
|
runtime: protocol.talent.runtime ?? null,
|
||||||
tier: draft.protocol.talentUpgrades,
|
tier: draft.protocol.talentUpgrades,
|
||||||
source: 'OS Protocol',
|
source: 'OS Protocol',
|
||||||
description: protocol.talent.description_template.replace(
|
description: protocol.talent.description_template.replace(
|
||||||
|
|||||||
@@ -7,15 +7,20 @@
|
|||||||
import type { DatabaseItem } from '$lib/database/items';
|
import type { DatabaseItem } from '$lib/database/items';
|
||||||
import MarkdownContent from './MarkdownContent.svelte';
|
import MarkdownContent from './MarkdownContent.svelte';
|
||||||
import WeaponModSlotIcon from './WeaponModSlotIcon.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 {
|
let {
|
||||||
item,
|
item,
|
||||||
hideDiscussionUtility = false,
|
hideDiscussionUtility = false,
|
||||||
anchor = true
|
anchor = true,
|
||||||
|
detail = false
|
||||||
}: {
|
}: {
|
||||||
item: DatabaseItem & { gameIcon?: GameIconName };
|
item: DatabaseItem & { gameIcon?: GameIconName };
|
||||||
hideDiscussionUtility?: boolean;
|
hideDiscussionUtility?: boolean;
|
||||||
anchor?: boolean;
|
anchor?: boolean;
|
||||||
|
detail?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
let protocolLevel = $state(1);
|
let protocolLevel = $state(1);
|
||||||
let talentUpgrades = $state(0);
|
let talentUpgrades = $state(0);
|
||||||
@@ -29,6 +34,14 @@
|
|||||||
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
||||||
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
|
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;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
|
<article id={anchor ? `database-item-${item.id}` : undefined} class="database-item-card" class:talent-card={item.variant === 'talent'} class:protocol-quality-standard={item.topBorderTone === 'standard'} class:protocol-quality-specialized={item.topBorderTone === 'specialized'} class:protocol-quality-superior={item.topBorderTone === 'superior'} class:protocol-quality-highend={item.topBorderTone === 'highend'}>
|
||||||
@@ -39,7 +52,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="database-item-identity">
|
<div class="database-item-identity">
|
||||||
<small>{item.kicker}</small>
|
<small>{item.kicker}</small>
|
||||||
<h2>{item.name}</h2>
|
<div class="database-item-name-line">
|
||||||
|
<h2>{item.name}</h2>
|
||||||
|
{#if primaryMechanicGroup()}
|
||||||
|
<DatabaseMechanicStatusBadge status={databaseMechanicGroupStatus(primaryMechanicGroup()!)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||||
<div class="database-header-status">
|
<div class="database-header-status">
|
||||||
@@ -53,7 +71,7 @@
|
|||||||
{#if item.protocolPreview}
|
{#if item.protocolPreview}
|
||||||
<section class="database-protocol-preview">
|
<section class="database-protocol-preview">
|
||||||
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
|
<div class="database-protocol-primary"><span>{item.protocolPreview.primaryStat}</span><b>{Number.isFinite(protocolPrimaryValue()) ? valueWithUnit(protocolPrimaryValue(), item.protocolPreview.primaryUnit) : 'NOT CONFIGURED'}</b></div>
|
||||||
{#if item.protocolPreview.talentDescription}
|
{#if item.protocolPreview.talentDescription && !hasMechanicGroups()}
|
||||||
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
|
<div class="database-protocol-controls"><label class="database-protocol-level"><span>LVL {protocolLevel}</span><input type="range" min="1" max="50" step="1" bind:value={protocolLevel} aria-label={`${item.name} level`}/></label>{#if item.protocolPreview.talentDescription}<div class="database-protocol-diamonds" role="group" aria-label={`${item.name} talent upgrades`}>{#each [1, 2, 3, 4, 5] as upgrade}<button type="button" class:active={talentUpgrades >= upgrade} aria-label={`Set talent upgrades to ${upgrade}`} aria-pressed={talentUpgrades === upgrade} onclick={() => talentUpgrades = talentUpgrades === upgrade ? 0 : upgrade}>◆</button>{/each}</div>{/if}</div>
|
||||||
@@ -86,15 +104,29 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.variant === 'talent'}
|
{#if hasMechanicGroups() && !hasSpecializationMechanics()}
|
||||||
|
<div class="database-mechanic-groups">
|
||||||
|
{#each mechanicGroups() as group (group.id)}
|
||||||
|
<DatabaseMechanicGroupCard {group} {detail}/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if item.variant === 'talent' && !hasMechanicGroups()}
|
||||||
<div class="database-talent-tiers">
|
<div class="database-talent-tiers">
|
||||||
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
<section><span>TIER I</span><p>{item.description ?? 'Description is not available.'}</p></section>
|
||||||
<section><span>TIER II</span><p>{item.secondaryDescription ?? 'Description is not available.'}</p></section>
|
{#if item.secondaryDescription}<section><span>TIER II</span><p>{item.secondaryDescription}</p></section>{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if !item.protocolPreview}
|
{:else if !item.protocolPreview && (!hasMechanicGroups() || hasSpecializationMechanics())}
|
||||||
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
{#if item.description}<p class:data-description={item.descriptionStyle === 'data'}>{item.description}</p>{/if}
|
||||||
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
{#if item.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if hasSpecializationMechanics()}
|
||||||
|
<div class="database-mechanic-groups database-specialization-effect-groups">
|
||||||
|
{#each mechanicGroups().filter(isSpecializationMechanic) as group (group.id)}
|
||||||
|
<DatabaseMechanicGroupCard {group} {detail} showIdentity/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if item.afterLines?.length}
|
{#if item.afterLines?.length}
|
||||||
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
<div class="database-item-lines after-description">{#each item.afterLines as line}<div><span>{line.label}</span><b>{line.value}</b></div>{/each}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||||
let dialog = $state<HTMLDivElement | null>(null);
|
let dialog = $state<HTMLDivElement | null>(null);
|
||||||
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
|
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
|
||||||
|
if (!item.preview) return;
|
||||||
selected = item;
|
selected = item;
|
||||||
trigger = button;
|
trigger = button;
|
||||||
await tick();
|
await tick();
|
||||||
@@ -32,8 +33,8 @@
|
|||||||
};
|
};
|
||||||
const trapDialogFocus = (event: KeyboardEvent) => {
|
const trapDialogFocus = (event: KeyboardEvent) => {
|
||||||
if (event.key !== 'Tab' || !dialog) return;
|
if (event.key !== 'Tab' || !dialog) return;
|
||||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
|
const focusable = [...dialog.querySelectorAll<HTMLElement>('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'));
|
.filter((element) => !element.hasAttribute('hidden') && element.getClientRects().length > 0);
|
||||||
const first = focusable[0];
|
const first = focusable[0];
|
||||||
const last = focusable.at(-1);
|
const last = focusable.at(-1);
|
||||||
if (!first || !last) return;
|
if (!first || !last) return;
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
|
<div class="database-preview-backdrop" role="presentation" onclick={(event) => { if (event.target === event.currentTarget) void closePreview(); }}>
|
||||||
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
|
<div class="database-preview-dialog" bind:this={dialog} role="dialog" aria-modal="true" aria-labelledby={`database-preview-${selected.id}`} tabindex="-1" onkeydown={trapDialogFocus}>
|
||||||
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
|
<header><div><small>DATABASE QUICK VIEW</small><strong id={`database-preview-${selected.id}`}>{selected.preview.name}</strong></div><button type="button" bind:this={closeButton} aria-label="Close details" onclick={() => void closePreview()}>×</button></header>
|
||||||
<DatabaseItemCard item={selected.preview}/>
|
<DatabaseItemCard item={selected.preview} anchor={false} detail/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
databaseMechanicGroupDisplaySourceLabel,
|
||||||
|
databaseMechanicGroupStatus,
|
||||||
|
databaseMechanicStatusLabel,
|
||||||
|
type DatabaseMechanicGroup
|
||||||
|
} from '$lib/database/items';
|
||||||
|
import DatabaseMechanicStatusBadge from './DatabaseMechanicStatusBadge.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
group,
|
||||||
|
detail = false,
|
||||||
|
showIdentity = false
|
||||||
|
}: {
|
||||||
|
group: DatabaseMechanicGroup;
|
||||||
|
detail?: boolean;
|
||||||
|
showIdentity?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const status = $derived(databaseMechanicGroupStatus(group));
|
||||||
|
const displaySource = $derived(databaseMechanicGroupDisplaySourceLabel(group));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="database-mechanic-group"
|
||||||
|
class:detail
|
||||||
|
class:specialization-effect={showIdentity}
|
||||||
|
data-status={status}
|
||||||
|
aria-label={`${group.label}: ${databaseMechanicStatusLabel(status)}`}
|
||||||
|
>
|
||||||
|
{#if showIdentity}
|
||||||
|
<header class="database-mechanic-group-identity">
|
||||||
|
<div>
|
||||||
|
<small>{group.label}</small>
|
||||||
|
{#if group.context}<strong>{group.context}</strong>{/if}
|
||||||
|
</div>
|
||||||
|
<DatabaseMechanicStatusBadge {status}/>
|
||||||
|
</header>
|
||||||
|
{:else if group.context}
|
||||||
|
<p class="database-mechanic-group-context">{group.context}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="database-mechanic-variants">
|
||||||
|
{#each group.variants as variant (variant.label)}
|
||||||
|
<section class="database-mechanic-variant" data-status={variant.status}>
|
||||||
|
<div class="database-mechanic-variant-label"><span>{variant.label}</span></div>
|
||||||
|
<div class="database-mechanic-behavior">
|
||||||
|
<small>{variant.status === 'modeled' ? 'MODELED BEHAVIOR' : 'AUTHORED DESCRIPTION'}</small>
|
||||||
|
<p aria-label={variant.behaviorText} title={detail ? undefined : variant.behaviorText}>{variant.behaviorText}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if detail}
|
||||||
|
<dl class="database-mechanic-display-source">
|
||||||
|
<div><dt>DISPLAY SOURCE</dt><dd>{displaySource}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
databaseMechanicStatusLabel,
|
||||||
|
type DatabaseMechanicStatus
|
||||||
|
} from '$lib/database/items';
|
||||||
|
|
||||||
|
let { status }: { status: DatabaseMechanicStatus } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<strong class="database-mechanic-status" class:not-modeled={status === 'not_modeled'}>
|
||||||
|
{databaseMechanicStatusLabel(status)}
|
||||||
|
</strong>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,53 @@ import type { GameIconName } from '$lib/game-icons';
|
|||||||
import type { GameDataTargetType } from '$lib/game-data-targets';
|
import type { GameDataTargetType } from '$lib/game-data-targets';
|
||||||
|
|
||||||
export type DatabaseBadge = { label: string; tone?: string };
|
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 DatabaseMeter = { label: string; value: number };
|
||||||
export type DatabaseWeaponModSlot = {
|
export type DatabaseWeaponModSlot = {
|
||||||
slot: string;
|
slot: string;
|
||||||
@@ -43,6 +90,7 @@ export type DatabaseItem = {
|
|||||||
afterLines?: DatabaseFact[];
|
afterLines?: DatabaseFact[];
|
||||||
iconGroups?: DatabaseIconGroup[];
|
iconGroups?: DatabaseIconGroup[];
|
||||||
weaponModSlots?: DatabaseWeaponModSlot[];
|
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||||
|
mechanicGroups?: DatabaseMechanicGroup[];
|
||||||
description?: string;
|
description?: string;
|
||||||
secondaryDescription?: string;
|
secondaryDescription?: string;
|
||||||
descriptionStyle?: 'prose' | 'data';
|
descriptionStyle?: 'prose' | 'data';
|
||||||
|
|||||||
@@ -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<typeof import('./mechanic-runtime-selection')>()),
|
||||||
|
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<string, MechanicRuntimeSelection | null>([
|
||||||
|
[
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, MechanicRuntimeSelection | null>;
|
||||||
|
protocolTalents: Record<string, MechanicRuntimeSelection | null>;
|
||||||
|
skillChipTalents: Record<string, MechanicRuntimeSelection | null>;
|
||||||
|
specializationAttachments: Record<string, MechanicRuntimeSelection | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<BuildMechanicRuntimes> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
type MechanicAttributeLabelResolver,
|
type MechanicAttributeLabelResolver,
|
||||||
} from '$lib/calculations/mechanic-public-renderer';
|
} from '$lib/calculations/mechanic-public-renderer';
|
||||||
import type { Database } from '@tursodatabase/database';
|
import type { Database } from '@tursodatabase/database';
|
||||||
|
import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
|
||||||
import {
|
import {
|
||||||
|
loadMechanicAuthoredSource,
|
||||||
loadMechanicAuthoredSourceById,
|
loadMechanicAuthoredSourceById,
|
||||||
type MechanicAuthoredSourceRecord,
|
type MechanicAuthoredSourceRecord,
|
||||||
} from './mechanic-authored-sources';
|
} from './mechanic-authored-sources';
|
||||||
@@ -37,6 +39,50 @@ export type MechanicPublicProjection = {
|
|||||||
variants: MechanicPublicVariantRecord[];
|
variants: MechanicPublicVariantRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stable public identity for a mechanic owner, without authoring-only fields. */
|
||||||
|
export type MechanicPublicProjectionOwnerRef = {
|
||||||
|
[Type in MechanicAuthoredOwner['type']]: Pick<
|
||||||
|
Extract<MechanicAuthoredOwner, { type: Type }>,
|
||||||
|
'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<string, unknown>;
|
||||||
|
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. */
|
/** Rebuilds one owner's complete public projection in the caller transaction. */
|
||||||
export async function refreshMechanicPublicProjectionInTransaction(
|
export async function refreshMechanicPublicProjectionInTransaction(
|
||||||
database: Database,
|
database: Database,
|
||||||
@@ -146,6 +192,356 @@ function isValidTimestamp(value: unknown): value is string {
|
|||||||
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
|
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MechanicPublicProjectionSnapshotRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
type MechanicPublicProjectionSnapshot = {
|
||||||
|
sourceByOwner: Map<string, MechanicPublicProjectionSnapshotRow>;
|
||||||
|
sourceById: Map<string, MechanicPublicProjectionSnapshotRow>;
|
||||||
|
variantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
slotsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
publicVariantsBySourceId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
selectionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
|
||||||
|
selectedRevisionBySourceId: Map<string, MechanicPublicProjectionSnapshotRow>;
|
||||||
|
poolsByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
clausesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
outcomesByRevisionId: Map<string, MechanicPublicProjectionSnapshotRow[]>;
|
||||||
|
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<string, MechanicPublicProjectionSnapshotRow[]>();
|
||||||
|
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<string, MechanicPublicProjectionSnapshotRow>();
|
||||||
|
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<MechanicPublicProjectionSnapshotRow[]> {
|
||||||
|
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<MechanicPublicProjectionSnapshot> {
|
||||||
|
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<string, MechanicPublicProjectionSnapshotRow>();
|
||||||
|
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
|
* Loads only a complete, internally consistent projection. The projection is
|
||||||
* cached text, so a malformed or partially replaced cache must never become a
|
* 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<MechanicPublicVariantRecord[]> {
|
||||||
|
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<MechanicPublicProjectionsByOwner> {
|
||||||
|
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(
|
export function mechanicPublicProjectionSnapshot(
|
||||||
selected: MechanicModelRevisionRecord | null,
|
selected: MechanicModelRevisionRecord | null,
|
||||||
projection: MechanicPublicVariantRecord[],
|
projection: MechanicPublicVariantRecord[],
|
||||||
|
|||||||
@@ -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<void>,
|
||||||
|
) {
|
||||||
|
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<void>,
|
||||||
|
) {
|
||||||
|
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'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MechanicAttributeLabelResolver> {
|
||||||
|
const rows = await (
|
||||||
|
await database.prepare('SELECT id,name FROM stat_attributes WHERE active=1')
|
||||||
|
).all();
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
type RuntimeSnapshot = {
|
||||||
|
sourceByOwner: Map<string, RuntimeSnapshotRow>;
|
||||||
|
variantsBySourceId: Map<string, RuntimeSnapshotRow[]>;
|
||||||
|
slotsBySourceId: Map<string, RuntimeSnapshotRow[]>;
|
||||||
|
selectionBySourceId: Map<string, RuntimeSnapshotRow>;
|
||||||
|
selectedRevisionBySourceId: Map<string, RuntimeSnapshotRow>;
|
||||||
|
poolsByRevisionId: Map<string, RuntimeSnapshotRow[]>;
|
||||||
|
clausesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
|
||||||
|
outcomesByRevisionId: Map<string, RuntimeSnapshotRow[]>;
|
||||||
|
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<string, RuntimeSnapshotRow>();
|
||||||
|
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<string, RuntimeSnapshotRow[]>();
|
||||||
|
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<RuntimeSnapshotRow[]> {
|
||||||
|
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<RuntimeSnapshot> {
|
||||||
|
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<string, RuntimeSnapshotRow>();
|
||||||
|
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<MechanicRuntimeSelection | null> {
|
||||||
|
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<RuntimeSnapshotCandidate | null> {
|
||||||
|
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<MechanicRuntimeOwnerReferenceKey, MechanicRuntimeSelection | null>
|
||||||
|
> {
|
||||||
|
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<MechanicRuntimeSelection | null> {
|
||||||
|
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<Map<MechanicRuntimeOwnerKey, MechanicRuntimeSelection | null>> {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof vi.fn>).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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Database } from '@tursodatabase/database';
|
import type { Database } from '@tursodatabase/database';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||||
|
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
|
||||||
|
|
||||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||||
const [
|
const [
|
||||||
@@ -37,6 +38,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
specializationNodes,
|
specializationNodes,
|
||||||
specializationRanks,
|
specializationRanks,
|
||||||
specializationRankEffects,
|
specializationRankEffects,
|
||||||
|
specializationRankCombatEffects,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
(
|
(
|
||||||
await database.prepare(
|
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',
|
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||||
)
|
)
|
||||||
).all(),
|
).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 {
|
return {
|
||||||
|
mechanics,
|
||||||
attributes,
|
attributes,
|
||||||
gear,
|
gear,
|
||||||
gearAugments,
|
gearAugments,
|
||||||
@@ -187,6 +215,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||||
talents: talents.map((talent) => ({
|
talents: talents.map((talent) => ({
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.talents[String(talent.id)] ?? null,
|
||||||
template:
|
template:
|
||||||
talentTemplates.find((item) => item.talent_id === talent.id)
|
talentTemplates.find((item) => item.talent_id === talent.id)
|
||||||
?.template ?? '',
|
?.template ?? '',
|
||||||
@@ -231,6 +260,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
|
||||||
effects: chipTalentEffects.filter(
|
effects: chipTalentEffects.filter(
|
||||||
(effect) => effect.talent_id === talent.id,
|
(effect) => effect.talent_id === talent.id,
|
||||||
),
|
),
|
||||||
@@ -260,6 +290,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
|
||||||
effects: protocolTalentEffects.filter(
|
effects: protocolTalentEffects.filter(
|
||||||
(effect) => effect.talent_id === talent.id,
|
(effect) => effect.talent_id === talent.id,
|
||||||
),
|
),
|
||||||
@@ -298,6 +329,23 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
|||||||
effect.node_id === node.id &&
|
effect.node_id === node.id &&
|
||||||
Number(effect.rank) === Number(rank.rank),
|
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,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createDraft } from '$lib/builder/loadout';
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
row: null as Record<string, unknown> | null,
|
row: null as Record<string, unknown> | null,
|
||||||
loadCatalog: vi.fn(),
|
loadCatalog: vi.fn(),
|
||||||
|
summarizeLoadout: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/database', () => ({
|
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', () => ({
|
vi.mock('$lib/server/shared-build-summary', () => ({
|
||||||
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
|
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { loadSharedBuildProjection } from './shared-build';
|
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') {
|
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
|
||||||
return {
|
return {
|
||||||
id: 'build-id',
|
id: 'build-id',
|
||||||
@@ -37,16 +127,101 @@ function buildRow(visibility: 'private' | 'unlisted' | 'public') {
|
|||||||
describe('shared Build access policy', () => {
|
describe('shared Build access policy', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.row = null;
|
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({
|
mocks.loadCatalog.mockReset().mockResolvedValue({
|
||||||
attributes: [],
|
attributes: [
|
||||||
|
{
|
||||||
|
id: 'firepower',
|
||||||
|
name: 'Firepower',
|
||||||
|
unit: '%',
|
||||||
|
category: 'Core',
|
||||||
|
runtime: selectedRuntime,
|
||||||
|
},
|
||||||
|
],
|
||||||
gear: [],
|
gear: [],
|
||||||
gearAugments: [],
|
gearAugments: [],
|
||||||
brands: [],
|
brands: [],
|
||||||
weapons: [],
|
weapons: [],
|
||||||
talents: [],
|
talents: [
|
||||||
chipSets: [],
|
{
|
||||||
protocols: [],
|
id: 'talent-1',
|
||||||
specializations: [],
|
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.visibility).toBe(visibility);
|
||||||
expect(result?.build.owner).toBe(false);
|
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 () => {
|
it('does not expose private Builds to public previews or other viewers', async () => {
|
||||||
mocks.row = buildRow('private');
|
mocks.row = buildRow('private');
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { avatarUrl } from '$lib/avatar';
|
import { avatarUrl } from '$lib/avatar';
|
||||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
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 { getDatabase } from '$lib/server/database';
|
||||||
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
||||||
|
|
||||||
@@ -8,6 +11,63 @@ export type SharedBuildAccessPolicy =
|
|||||||
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
|
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
|
||||||
type BuildVisibility = 'public' | 'unlisted' | 'private';
|
type BuildVisibility = 'public' | 'unlisted' | 'private';
|
||||||
|
|
||||||
|
function publicFields<
|
||||||
|
Value extends Record<string, unknown>,
|
||||||
|
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(
|
export async function loadSharedBuildProjection(
|
||||||
slug: string,
|
slug: string,
|
||||||
access: SharedBuildAccessPolicy,
|
access: SharedBuildAccessPolicy,
|
||||||
@@ -92,13 +152,30 @@ export async function loadSharedBuildProjection(
|
|||||||
updatedAt: String(build.updated_at),
|
updatedAt: String(build.updated_at),
|
||||||
},
|
},
|
||||||
loadout,
|
loadout,
|
||||||
gear,
|
gear: gear.map((item) =>
|
||||||
weapons,
|
publicFields(item, ['id', 'name', 'slot', 'tier', 'base_armor_min']),
|
||||||
chips,
|
),
|
||||||
protocols,
|
weapons: weapons.map((item) =>
|
||||||
brands,
|
publicFields(item, [
|
||||||
summary,
|
'id',
|
||||||
attributes: summaryCatalog.attributes,
|
'name',
|
||||||
talents: summaryCatalog.talents,
|
'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']),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { publicOrigin } from '$lib/server/public-origin';
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||||
|
import { loadBuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime';
|
||||||
|
|
||||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||||
const slugify = (value: string) =>
|
const slugify = (value: string) =>
|
||||||
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
specializationNodes,
|
specializationNodes,
|
||||||
specializationRanks,
|
specializationRanks,
|
||||||
specializationRankEffects,
|
specializationRankEffects,
|
||||||
|
specializationRankCombatEffects,
|
||||||
specializationRankCosts,
|
specializationRankCosts,
|
||||||
progression,
|
progression,
|
||||||
rankProfiles,
|
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',
|
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||||
)
|
)
|
||||||
).all(),
|
).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(
|
await database.prepare(
|
||||||
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
'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(),
|
).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 = (
|
const iconUrl = (
|
||||||
type:
|
type:
|
||||||
| 'talent'
|
| 'talent'
|
||||||
@@ -488,6 +515,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
};
|
};
|
||||||
const configuredTalents = talents.map((talent) => ({
|
const configuredTalents = talents.map((talent) => ({
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.talents[String(talent.id)] ?? null,
|
||||||
iconUrl: iconUrl('talent', talent.id),
|
iconUrl: iconUrl('talent', talent.id),
|
||||||
template:
|
template:
|
||||||
talentTemplates.find((item) => item.talent_id === 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,
|
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||||
publicOrigin: publicOrigin(url.origin),
|
publicOrigin: publicOrigin(url.origin),
|
||||||
initialBuild,
|
initialBuild,
|
||||||
|
mechanics,
|
||||||
attributes,
|
attributes,
|
||||||
coreRanges,
|
coreRanges,
|
||||||
gear: gear.map((item) => ({
|
gear: gear.map((item) => ({
|
||||||
@@ -584,6 +613,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
|
||||||
effects: chipTalentEffects.filter(
|
effects: chipTalentEffects.filter(
|
||||||
(effect) => effect.talent_id === talent.id,
|
(effect) => effect.talent_id === talent.id,
|
||||||
),
|
),
|
||||||
@@ -614,6 +644,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
talent: talent
|
talent: talent
|
||||||
? {
|
? {
|
||||||
...talent,
|
...talent,
|
||||||
|
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
|
||||||
effects: protocolTalentEffects.filter(
|
effects: protocolTalentEffects.filter(
|
||||||
(effect) => effect.talent_id === talent.id,
|
(effect) => effect.talent_id === talent.id,
|
||||||
),
|
),
|
||||||
@@ -664,6 +695,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
|||||||
effect.node_id === node.id &&
|
effect.node_id === node.id &&
|
||||||
Number(effect.rank) === Number(rank.rank),
|
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(
|
costs: specializationRankCosts.filter(
|
||||||
(cost) =>
|
(cost) =>
|
||||||
cost.node_id === node.id &&
|
cost.node_id === node.id &&
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { getDatabase } from '$lib/server/database';
|
import { getDatabase } from '$lib/server/database';
|
||||||
import { getRegistrySearch } from '$lib/server/pagination';
|
import { getRegistrySearch } from '$lib/server/pagination';
|
||||||
import { databaseCategories } from '$lib/database/categories';
|
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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
const tables = {
|
const tables = {
|
||||||
@@ -28,110 +38,465 @@ export const load: PageServerLoad = async ({ url }) => {
|
|||||||
return { ...category, count: Number(row?.count ?? 0) };
|
return { ...category, count: Number(row?.count ?? 0) };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const searchResults = search
|
const rawSearchResults = search
|
||||||
? await (
|
? await (
|
||||||
await database.prepare(`
|
await database.prepare(`
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT 'gear' AS slug,'Gear' AS category,item.name,
|
SELECT 'gear' AS slug,'Gear' AS category,item.id AS parent_id,item.name,
|
||||||
item.slot || ' · T' || item.tier AS detail
|
item.slot || ' · T' || item.tier AS detail
|
||||||
FROM gear item
|
FROM gear item
|
||||||
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
WHERE item.active=1 AND (
|
||||||
UNION ALL
|
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
SELECT 'weapons','Weapons',item.name,
|
OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
item.category || ' · ' || item.damage_type || ' damage'
|
OR EXISTS (
|
||||||
FROM weapons item
|
SELECT 1 FROM gear_talents link
|
||||||
WHERE item.active=1 AND (
|
JOIN talents linked_talent
|
||||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
ON linked_talent.id=link.talent_id AND linked_talent.active=1
|
||||||
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
JOIN mechanic_authored_sources source
|
||||||
)
|
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
|
||||||
UNION ALL
|
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||||
SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type
|
WHERE link.gear_id=item.id
|
||||||
FROM weapon_mods item
|
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
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'
|
UNION ALL
|
||||||
FROM gear_mods item
|
SELECT 'weapons','Weapons',item.id,item.name,
|
||||||
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
item.category || ' · ' || item.damage_type || ' damage'
|
||||||
UNION ALL
|
FROM weapons item
|
||||||
SELECT 'os-protocols','OS Protocols',item.name,
|
WHERE item.active=1 AND (
|
||||||
item.quality || ' · ' || item.primary_attribute_type
|
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
FROM os_protocols item
|
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id
|
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
WHERE item.active=1 AND (
|
OR EXISTS (
|
||||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
|
SELECT 1 FROM talents linked_talent
|
||||||
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
JOIN mechanic_authored_sources source
|
||||||
OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
|
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
|
||||||
)
|
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||||
UNION ALL
|
WHERE linked_talent.active=1
|
||||||
SELECT 'talents','Talents',item.name,item.category
|
AND (
|
||||||
FROM talents item
|
linked_talent.id=item.exotic_talent_id
|
||||||
WHERE item.active=1 AND (
|
OR EXISTS (
|
||||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
SELECT 1 FROM weapon_talent_options link
|
||||||
OR EXISTS (
|
WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id
|
||||||
SELECT 1 FROM talent_tiers tier
|
)
|
||||||
WHERE tier.talent_id=item.id AND (
|
)
|
||||||
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
|
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
)
|
||||||
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
)
|
||||||
)
|
UNION ALL
|
||||||
)
|
SELECT 'weapon-mods','Weapon Mods',item.id,item.name,item.slot_type
|
||||||
)
|
FROM weapon_mods item
|
||||||
UNION ALL
|
WHERE item.active=1 AND (
|
||||||
SELECT 'brands','Brands',item.name,
|
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
COALESCE((
|
OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
SELECT GROUP_CONCAT(attribute.name, ', ')
|
)
|
||||||
FROM brand_set_effects effect
|
UNION ALL
|
||||||
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
|
SELECT 'gear-mods','Gear Mods',item.id,item.name,'Gear modification'
|
||||||
WHERE effect.brand_set_id=item.id
|
FROM gear_mods item
|
||||||
),'Brand Set bonuses')
|
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
FROM brand_sets item
|
UNION ALL
|
||||||
WHERE item.active=1 AND (
|
SELECT 'os-protocols','OS Protocols',item.id,item.name,
|
||||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
item.quality || ' · ' || item.primary_attribute_type
|
||||||
OR EXISTS (
|
FROM os_protocols item
|
||||||
SELECT 1 FROM brand_set_effects effect
|
WHERE item.active=1 AND (
|
||||||
JOIN stat_attributes attribute ON attribute.id=effect.stat_attribute_id
|
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
WHERE effect.brand_set_id=item.id AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
)
|
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
)
|
OR EXISTS (
|
||||||
UNION ALL
|
SELECT 1 FROM os_protocol_talents talent
|
||||||
SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set')
|
WHERE talent.protocol_id=item.id AND (
|
||||||
FROM skill_chip_sets item
|
talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id
|
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
WHERE item.active=1 AND (
|
OR EXISTS (
|
||||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
SELECT 1 FROM mechanic_authored_sources source
|
||||||
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
|
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||||
)
|
WHERE source.owner_type='os_protocol_talent' AND source.owner_id=talent.id
|
||||||
UNION ALL
|
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
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 (
|
UNION ALL
|
||||||
SELECT 1 FROM specialization_focuses focus
|
SELECT 'talents','Talents',item.id,item.name,item.category
|
||||||
WHERE focus.specialization_id=item.id AND focus.active=1
|
FROM talents item
|
||||||
AND focus.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
WHERE item.active=1 AND (
|
||||||
)
|
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
OR EXISTS (
|
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
SELECT 1 FROM specialization_abilities ability
|
OR EXISTS (
|
||||||
WHERE ability.specialization_id=item.id AND ability.active=1
|
SELECT 1 FROM talent_tiers tier
|
||||||
AND ability.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
WHERE tier.talent_id=item.id AND (
|
||||||
)
|
tier.description LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
)
|
OR tier.trigger_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
) matches
|
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||||
ORDER BY name COLLATE NOCASE
|
)
|
||||||
LIMIT 40
|
)
|
||||||
`)
|
OR EXISTS (
|
||||||
).all(...Array(27).fill(pattern))
|
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<string>();
|
||||||
|
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<string, MechanicPublicProjectionOwnerRef[]>();
|
||||||
|
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 {
|
return {
|
||||||
registries: counts,
|
registries: counts,
|
||||||
total: counts.reduce((total, registry) => total + registry.count, 0),
|
total: counts.reduce((total, registry) => total + registry.count, 0),
|
||||||
search,
|
search,
|
||||||
searchResults: searchResults.map((result) => ({
|
searchResults: searchResults.map((result) => {
|
||||||
slug: String(result.slug),
|
const groups = (
|
||||||
category: String(result.category),
|
ownersByResult.get(
|
||||||
name: String(result.name),
|
resultKey(String(result.slug), String(result.parent_id)),
|
||||||
detail: String(result.detail ?? ''),
|
) ?? []
|
||||||
})),
|
)
|
||||||
|
.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 ?? {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import DatabaseMechanicStatusBadge from '$lib/components/database/DatabaseMechanicStatusBadge.svelte';
|
||||||
|
import { isDatabaseMechanicStatus, type DatabaseMechanicStatus } from '$lib/database/items';
|
||||||
|
|
||||||
|
type DatabaseSearchResult = {
|
||||||
|
slug: string;
|
||||||
|
category: string;
|
||||||
|
name: string;
|
||||||
|
detail: string;
|
||||||
|
behaviorText?: string;
|
||||||
|
status?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const onlineRegistries = $derived(data.registries.filter((registry) => registry.count > 0).length);
|
const onlineRegistries = $derived(data.registries.filter((registry) => registry.count > 0).length);
|
||||||
|
const behaviorText = (result: DatabaseSearchResult) => result.behaviorText ?? result.detail;
|
||||||
|
const status = (result: DatabaseSearchResult): DatabaseMechanicStatus | null =>
|
||||||
|
isDatabaseMechanicStatus(result.status) ? result.status : null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -31,11 +46,13 @@
|
|||||||
{#if data.searchResults.length}
|
{#if data.searchResults.length}
|
||||||
<div>
|
<div>
|
||||||
{#each data.searchResults as result}
|
{#each data.searchResults as result}
|
||||||
|
{@const resultStatus = status(result)}
|
||||||
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
|
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
|
||||||
<small>{result.category}</small>
|
<small>{result.category}</small>
|
||||||
<strong>{result.name}</strong>
|
<strong class="database-global-result-identity">{result.name}</strong>
|
||||||
<span>{result.detail}</span>
|
{#if resultStatus}<DatabaseMechanicStatusBadge status={resultStatus}/>{/if}
|
||||||
<b>VIEW →</b>
|
<span class="database-global-result-behavior" aria-label={behaviorText(result)} title={behaviorText(result)}>{behaviorText(result)}</span>
|
||||||
|
<b class="database-global-result-navigation">VIEW →</b>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { databaseCategory } from '$lib/database/categories';
|
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 type { GameIconName } from '$lib/game-icons';
|
||||||
import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout';
|
import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +44,15 @@ import {
|
|||||||
publicWeaponModSlots,
|
publicWeaponModSlots,
|
||||||
weaponModSlotDefinitionByName,
|
weaponModSlotDefinitionByName,
|
||||||
} from '$lib/weapons/mod-slots';
|
} 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 number = (value: unknown) => {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -65,19 +74,9 @@ const weaponIcon = (category: unknown) =>
|
|||||||
String(category).toLowerCase() as GameIconName;
|
String(category).toLowerCase() as GameIconName;
|
||||||
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
||||||
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
||||||
const parsedObject = (value: unknown): Record<string, unknown> => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(String(value ?? '{}'));
|
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const talentCardItem = (
|
const talentCardItem = (
|
||||||
row: Record<string, any>,
|
row: Record<string, unknown>,
|
||||||
mechanics: Array<Record<string, any>>,
|
mechanicGroups: DatabaseMechanicGroup[],
|
||||||
weaponSlotOverride?: number,
|
weaponSlotOverride?: number,
|
||||||
): DatabaseItem => {
|
): DatabaseItem => {
|
||||||
const talentCategory = String(row.category);
|
const talentCategory = String(row.category);
|
||||||
@@ -91,27 +90,6 @@ const talentCardItem = (
|
|||||||
isWeaponTalent && weaponSlot
|
isWeaponTalent && weaponSlot
|
||||||
? `WEAPON ${weaponSlot}`
|
? `WEAPON ${weaponSlot}`
|
||||||
: normalizedCategory.toUpperCase();
|
: 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 filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
|
||||||
const destination = isWeaponTalent ? 'weapons' : 'gear';
|
const destination = isWeaponTalent ? 'weapons' : 'gear';
|
||||||
|
|
||||||
@@ -136,14 +114,7 @@ const talentCardItem = (
|
|||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
facts: [],
|
facts: [],
|
||||||
description: row.tier_i
|
mechanicGroups,
|
||||||
? String(row.tier_i)
|
|
||||||
: 'Description is not available.',
|
|
||||||
secondaryDescription: row.tier_ii
|
|
||||||
? String(row.tier_ii)
|
|
||||||
: 'Description is not available.',
|
|
||||||
descriptionStyle: 'data',
|
|
||||||
afterLines,
|
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
|
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
|
||||||
@@ -179,6 +150,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
}> = [];
|
}> = [];
|
||||||
let countSql = '';
|
let countSql = '';
|
||||||
let rowsSql = '';
|
let rowsSql = '';
|
||||||
|
let behaviorSearchCandidateSql: string | null = null;
|
||||||
|
|
||||||
switch (category.slug) {
|
switch (category.slug) {
|
||||||
case 'gear': {
|
case 'gear': {
|
||||||
@@ -195,7 +167,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
).all(),
|
).all(),
|
||||||
(
|
(
|
||||||
await database.prepare(`SELECT talents.id,talents.name,
|
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
|
icons.updated_at FROM talents
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
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')
|
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'];
|
const conditions = ['item.active=1'];
|
||||||
queryArgs = [];
|
queryArgs = [];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
conditions.push(`(
|
||||||
queryArgs.push(pattern);
|
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) {
|
if (selectedSlots.length) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -244,6 +226,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
queryArgs.push(...selectedTalents);
|
queryArgs.push(...selectedTalents);
|
||||||
}
|
}
|
||||||
const where = conditions.join(' AND ');
|
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}`;
|
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
|
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM gear item
|
FROM gear item
|
||||||
@@ -282,7 +267,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
options: talentRows.map((row) => ({
|
options: talentRows.map((row) => ({
|
||||||
value: String(row.id),
|
value: String(row.id),
|
||||||
label: String(row.name),
|
label: String(row.name),
|
||||||
description: String(row.tier_i_description ?? ''),
|
|
||||||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
@@ -291,13 +275,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
}
|
}
|
||||||
case 'weapons': {
|
case 'weapons': {
|
||||||
const talentRows = await (
|
const talentRows = await (
|
||||||
await database.prepare(`SELECT DISTINCT talents.id,talents.name,
|
await database.prepare(`SELECT talents.id,talents.name,icons.updated_at
|
||||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
FROM talents
|
||||||
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
|
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
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`)
|
ORDER BY talents.name COLLATE NOCASE`)
|
||||||
).all();
|
).all();
|
||||||
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
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'];
|
const conditions = ['item.active=1'];
|
||||||
queryArgs = [];
|
queryArgs = [];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
conditions.push(`(
|
||||||
queryArgs.push(pattern);
|
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) {
|
if (selectedTypes.length) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -331,11 +339,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
queryArgs.push(...selectedDamageTypes);
|
queryArgs.push(...selectedDamageTypes);
|
||||||
}
|
}
|
||||||
if (selectedTalents.length) {
|
if (selectedTalents.length) {
|
||||||
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
|
conditions.push(`(
|
||||||
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
EXISTS (
|
||||||
queryArgs.push(...selectedTalents);
|
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 ');
|
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}`;
|
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,
|
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||||
native.damage_min,native.damage_max,native.reference_level,
|
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) => ({
|
options: talentRows.map((row) => ({
|
||||||
value: String(row.id),
|
value: String(row.id),
|
||||||
label: String(row.name),
|
label: String(row.name),
|
||||||
description: String(row.tier_i_description ?? ''),
|
|
||||||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
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}
|
WHERE item.active=1${searchClause}
|
||||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||||
break;
|
break;
|
||||||
case 'os-protocols':
|
case 'os-protocols': {
|
||||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
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,
|
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
|
render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM os_protocols item
|
FROM os_protocols item
|
||||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
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 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 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
|
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 ?`;
|
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'talents': {
|
case 'talents': {
|
||||||
const talentSlots = [
|
const talentSlots = [
|
||||||
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
|
{ 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'];
|
const conditions = ['item.active=1'];
|
||||||
queryArgs = [];
|
queryArgs = [];
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
conditions.push(`(
|
||||||
queryArgs.push(pattern);
|
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) {
|
if (selectedTalentSlots.length) {
|
||||||
const slotConditions = selectedTalentSlots.map((slot) => {
|
const slotConditions = selectedTalentSlots.map((slot) => {
|
||||||
@@ -467,10 +509,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const where = conditions.join(' AND ');
|
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}`;
|
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
|
||||||
rowsSql = `SELECT item.*,
|
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 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,
|
(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
|
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}
|
WHERE item.active=1${searchClause}
|
||||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||||
break;
|
break;
|
||||||
case 'skill-chips':
|
case 'skill-chips': {
|
||||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
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,
|
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
|
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||||
FROM skill_chip_sets item
|
FROM skill_chip_sets item
|
||||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
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 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 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
|
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 ?`;
|
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||||
break;
|
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.*,
|
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.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,
|
(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
|
FROM specializations item
|
||||||
LEFT JOIN game_data_icons icons ON icons.entity_type='specialization' AND icons.entity_id=item.id
|
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
|
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 ?`;
|
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = Number(
|
// Cache text may identify candidates, but only canonical projections decide
|
||||||
(await (await database.prepare(countSql)).get(...queryArgs))?.total ?? 0,
|
// 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<Record<string, unknown>> = [];
|
||||||
|
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 pagination = getPagination(url, total);
|
||||||
const rows = await (
|
const rows =
|
||||||
await database.prepare(rowsSql)
|
validatedBehaviorSearchIds !== null && !validatedBehaviorSearchIds.length
|
||||||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
? []
|
||||||
const protocolTalentEffects =
|
: await (
|
||||||
category.slug === 'os-protocols' && rows.length
|
await database.prepare(
|
||||||
? await (
|
validatedBehaviorSearchIds
|
||||||
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
|
? `${rowsSql.slice(0, rowsSql.lastIndexOf('ORDER BY'))}AND item.id IN (${validatedBehaviorSearchIds
|
||||||
FROM os_protocol_talents talents
|
.map(() => '?')
|
||||||
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
|
.join(
|
||||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
',',
|
||||||
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
|
)}) ${rowsSql.slice(rowsSql.lastIndexOf('ORDER BY'))}`
|
||||||
ORDER BY talents.protocol_id,effects.sort_order`)
|
: rowsSql,
|
||||||
).all(...rows.map((row) => row.id))
|
)
|
||||||
: [];
|
).all(
|
||||||
|
...queryArgs,
|
||||||
|
...(validatedBehaviorSearchIds ?? []),
|
||||||
|
pagination.perPage,
|
||||||
|
pagination.offset,
|
||||||
|
);
|
||||||
const weaponRelations =
|
const weaponRelations =
|
||||||
category.slug === 'weapons' && rows.length
|
category.slug === 'weapons' && rows.length
|
||||||
? await (
|
? await (
|
||||||
await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
|
await database.prepare(`SELECT relation.weapon_id,relation.talent_slot,relation.kind AS relation_kind,
|
||||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
talents.*,COALESCE(scopes.scope,CASE relation.kind WHEN 'exotic' THEN 'Exotic' END) AS weapon_scope,
|
||||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii,
|
|
||||||
icons.updated_at AS icon_updated_at
|
icons.updated_at AS icon_updated_at
|
||||||
FROM weapon_talent_options options
|
FROM (
|
||||||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
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
|
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(',')})
|
WHERE relation.weapon_id IN (${rows.map(() => '?').join(',')})
|
||||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
ORDER BY relation.weapon_id,relation.kind,relation.talent_slot,talents.name COLLATE NOCASE`)
|
||||||
).all(...rows.map((row) => row.id))
|
).all(...rows.map((row) => row.id))
|
||||||
: [];
|
: [];
|
||||||
const weaponModSlotRows =
|
const weaponModSlotRows =
|
||||||
@@ -594,16 +817,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
? await (
|
? await (
|
||||||
await database.prepare(`SELECT relation.*,icons.updated_at
|
await database.prepare(`SELECT relation.*,icons.updated_at
|
||||||
FROM (
|
FROM (
|
||||||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
|
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,NULL AS category
|
||||||
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
|
|
||||||
NULL AS tier_i,NULL AS tier_ii
|
|
||||||
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
||||||
WHERE brands.active=1
|
WHERE brands.active=1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
|
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,talents.category
|
||||||
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
|
|
||||||
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
||||||
WHERE talents.active=1
|
WHERE talents.active=1
|
||||||
) relation
|
) 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`)
|
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)
|
||||||
).all(...rows.map((row) => row.id))
|
).all(...rows.map((row) => row.id))
|
||||||
: [];
|
: [];
|
||||||
const talentIds = [
|
const specializationMechanicRows =
|
||||||
...new Set(
|
category.slug === 'specializations' && rows.length
|
||||||
category.slug === 'talents'
|
? await (
|
||||||
? rows.map((row) => String(row.id))
|
await database.prepare(`SELECT attachment.id AS attachment_id,
|
||||||
: category.slug === 'weapons'
|
focus.specialization_id,focus.name AS focus_name,focus.display_order AS focus_display_order,
|
||||||
? weaponRelations.map((relation) => String(relation.id))
|
node.name AS node_name,node.row_number,node.column_number,
|
||||||
: category.slug === 'gear'
|
attachment.rank,attachment.effect_order,combat_effect.name AS combat_effect_name
|
||||||
? gearRelations
|
FROM specialization_tree_rank_combat_effects attachment
|
||||||
.filter((relation) => relation.kind === 'talent')
|
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
|
||||||
.map((relation) => String(relation.id))
|
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
|
const uniqueMechanicOwners = [
|
||||||
? await (
|
...new Map(
|
||||||
await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
|
mechanicOwners.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]),
|
||||||
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
|
).values(),
|
||||||
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
|
] as MechanicPublicProjectionOwnerRef[];
|
||||||
outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope,
|
const publicMechanics: MechanicPublicProjectionsByOwner =
|
||||||
outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit,
|
uniqueMechanicOwners.length
|
||||||
outcomes.sort_order
|
? await loadMechanicPublicProjectionsForOwners(
|
||||||
FROM conditional_effect_rules rules
|
database,
|
||||||
JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1
|
uniqueMechanicOwners,
|
||||||
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
|
)
|
||||||
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
: ({} as MechanicPublicProjectionsByOwner);
|
||||||
WHERE rules.source_type='talent' AND rules.configured=1
|
const talentMechanicGroups = (id: unknown) => {
|
||||||
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
|
const group = databaseMechanicGroup(
|
||||||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)
|
{ type: 'talent', id: String(id) },
|
||||||
).all(...talentIds)
|
publicMechanics,
|
||||||
: [];
|
{ label: 'TALENT' },
|
||||||
const mechanicsFor = (talentId: unknown) =>
|
|
||||||
talentMechanics.filter(
|
|
||||||
(mechanic) => String(mechanic.talent_id) === String(talentId),
|
|
||||||
);
|
);
|
||||||
|
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) => {
|
const items: DatabaseItem[] = rows.map((row) => {
|
||||||
switch (category.slug) {
|
switch (category.slug) {
|
||||||
@@ -737,7 +1028,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
...relation,
|
...relation,
|
||||||
icon_updated_at: relation.updated_at,
|
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
|
relatedTalents
|
||||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
.filter((relation) =>
|
||||||
|
slot === 'exotic'
|
||||||
|
? relation.relation_kind === 'exotic'
|
||||||
|
: Number(relation.talent_slot) === slot,
|
||||||
|
)
|
||||||
.map((relation) => {
|
.map((relation) => {
|
||||||
const talentIcon = iconUrl(
|
const talentIcon = iconUrl(
|
||||||
'talent',
|
'talent',
|
||||||
@@ -840,8 +1135,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
iconUrl: talentIcon,
|
iconUrl: talentIcon,
|
||||||
preview: talentCardItem(
|
preview: talentCardItem(
|
||||||
relation,
|
relation,
|
||||||
mechanicsFor(relation.id),
|
talentMechanicGroups(relation.id),
|
||||||
slot,
|
typeof slot === 'number' ? slot : undefined,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -899,6 +1194,15 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
items: talentItems(2),
|
items: talentItems(2),
|
||||||
emptyLabel: 'NO TALENTS ASSIGNED',
|
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 {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
@@ -979,22 +1284,14 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||||||
primaryUnit: String(row.primary_unit ?? ''),
|
primaryUnit: String(row.primary_unit ?? ''),
|
||||||
primaryBaseValue: Number(row.base_value),
|
primaryBaseValue: Number(row.base_value),
|
||||||
talentDescription: row.talent_description
|
talentDescription: mechanicGroups[0]?.variants[0]?.behaviorText,
|
||||||
? String(row.talent_description)
|
talentEffects: [],
|
||||||
: undefined,
|
|
||||||
talentEffects: protocolTalentEffects
|
|
||||||
.filter((effect) => effect.protocol_id === row.id)
|
|
||||||
.map((effect) => ({
|
|
||||||
baseValue: Number(effect.base_value),
|
|
||||||
unit: String(effect.unit ?? ''),
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
description: row.talent_description
|
mechanicGroups,
|
||||||
? String(row.talent_description)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'talents':
|
case 'talents':
|
||||||
return talentCardItem(row, mechanicsFor(row.id));
|
return talentCardItem(row, talentMechanicGroups(row.id));
|
||||||
case 'brands': {
|
case 'brands': {
|
||||||
const effects = brandEffects.filter(
|
const effects = brandEffects.filter(
|
||||||
(effect) => effect.brand_set_id === row.id,
|
(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 {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
name: String(row.name),
|
name: String(row.name),
|
||||||
@@ -1044,10 +1342,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
description: row.talent_description
|
mechanicGroups,
|
||||||
? String(row.talent_description)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'specializations':
|
case 'specializations':
|
||||||
return {
|
return {
|
||||||
id: String(row.id),
|
id: String(row.id),
|
||||||
@@ -1070,6 +1367,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
|||||||
{ label: 'ABILITIES', value: list(row.ability_names) },
|
{ label: 'ABILITIES', value: list(row.ability_names) },
|
||||||
],
|
],
|
||||||
description: row.description ? String(row.description) : undefined,
|
description: row.description ? String(row.description) : undefined,
|
||||||
|
mechanicGroups: specializationMechanicGroups(row.id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}) as DatabaseItem[];
|
}) as DatabaseItem[];
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
<DatabaseCategoryNav active={data.category.slug}/>
|
<DatabaseCategoryNav active={data.category.slug}/>
|
||||||
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
|
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
|
||||||
<section class="database-item-grid">
|
<section class="database-item-grid">
|
||||||
{#each data.items as item}
|
{#each data.items as item (item.id)}
|
||||||
<DatabaseItemCard item={item as any}/>
|
<DatabaseItemCard {item}/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="database-empty">
|
<div class="database-empty">
|
||||||
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
|
<span>◇</span><strong>{data.search ? 'NO MATCHING RECORDS' : 'NO ACTIVE RECORDS'}</strong>
|
||||||
|
|||||||
@@ -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' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user