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;
|
||||
- relationship lines and tier descriptions only when applicable.
|
||||
|
||||
#### Public mechanic behavior status
|
||||
|
||||
Mechanic owners use the shared integrated card hierarchy. The public DTO carries a
|
||||
stable group ID, owner type, label and optional context, plus explicitly labeled
|
||||
variants. Each variant provides public behavior text, `modeled` or `not_modeled`
|
||||
status, and `model` or `authored` display source. Public cards never receive
|
||||
lifecycle, readiness, revision, fingerprint, or program data.
|
||||
|
||||
Every owner group displays one textual `MODELED` or `NOT MODELED` badge beside
|
||||
its identity. `MODELED` uses the model surface, model border, and blue
|
||||
informational text. `NOT MODELED` uses neutral borders and muted text. Orange
|
||||
remains reserved for navigation, focus, and actions. Each behavior block labels
|
||||
its displayed basis as `MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`.
|
||||
|
||||
Talent groups retain Tier I and Tier II variants. Exotic Talents omit Tier II
|
||||
when the authored source omits it. OS Protocol Talents use a Base variant beside
|
||||
their existing level and upgrade controls. Skill Chip Talents use Minimum and
|
||||
Maximum variants in the three-piece section. Specialization tree-rank effects
|
||||
remain independent groups under their Focus, node, and rank context.
|
||||
|
||||
Compact cards visually clamp long behavior text without removing it from the
|
||||
accessible name or linked detail. Quick views render the same shared card at a
|
||||
wider reading measure, retain the binary badge, and add `DISPLAY SOURCE` with
|
||||
`MODELED BEHAVIOR` or `AUTHORED DESCRIPTION`. Global Database results show the
|
||||
owner category, record name, current public behavior, binary status, and an
|
||||
explicit registry navigation label. Status is textual at every breakpoint;
|
||||
mobile places it after identity and before behavior.
|
||||
|
||||
When a record represents an ordered progression rather than independent facts,
|
||||
use the stacked fact treatment. Brand Set bonuses are shown vertically in two-,
|
||||
three-, then four-piece order; do not add a redundant “3/3 bonuses” badge when
|
||||
|
||||
+232
@@ -19404,3 +19404,235 @@ a.secondary:active {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Public mechanic behavior status: Variant A integrated card hierarchy. */
|
||||
.database-item-name-line {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.database-item-name-line > h2 {
|
||||
min-width: 0;
|
||||
}
|
||||
.database-mechanic-status {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--shd-model-border);
|
||||
background: var(--shd-model-surface);
|
||||
color: var(--shd-info);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.7px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.database-mechanic-status.not-modeled {
|
||||
border-color: var(--shd-border-strong);
|
||||
background: var(--shd-surface-active);
|
||||
color: var(--shd-text-muted);
|
||||
}
|
||||
.database-mechanic-groups {
|
||||
display: grid;
|
||||
gap: var(--shd-gap-sm);
|
||||
margin: 10px 12px 0;
|
||||
}
|
||||
.database-mechanic-group {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.database-mechanic-group-context {
|
||||
margin: 0;
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.database-mechanic-group-identity {
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
.database-mechanic-group-identity > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
.database-mechanic-group-identity small,
|
||||
.database-mechanic-behavior > small,
|
||||
.database-mechanic-variant-label > span,
|
||||
.database-mechanic-display-source dt {
|
||||
color: var(--shd-text-dim);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.8px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.database-mechanic-group-identity strong {
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--shd-text);
|
||||
font-size: var(--shd-type-supporting);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.database-mechanic-variants {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.database-mechanic-variant {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
border: 1px solid var(--shd-model-border);
|
||||
background: var(--shd-model-surface);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] {
|
||||
border-color: var(--shd-border);
|
||||
background: var(--shd-surface-raised);
|
||||
}
|
||||
.database-mechanic-variant-label {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
padding: 10px 8px;
|
||||
border-right: 1px solid var(--shd-model-border);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
|
||||
border-color: var(--shd-border);
|
||||
}
|
||||
.database-mechanic-variant-label > span {
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label > span {
|
||||
color: var(--shd-text-muted);
|
||||
}
|
||||
.database-mechanic-behavior {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-left: 2px solid var(--shd-info);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior {
|
||||
border-left-color: var(--shd-text-dim);
|
||||
}
|
||||
.database-mechanic-behavior > small {
|
||||
display: block;
|
||||
color: var(--shd-info);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-behavior > small {
|
||||
color: var(--shd-text-muted);
|
||||
}
|
||||
.database-mechanic-behavior > p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--shd-text);
|
||||
font: var(--shd-type-supporting)/1.5 var(--shd-font-editorial);
|
||||
}
|
||||
.database-mechanic-group:not(.detail) .database-mechanic-behavior > p {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
.database-mechanic-display-source {
|
||||
margin: 0;
|
||||
border: 1px solid var(--shd-border);
|
||||
background: var(--shd-surface);
|
||||
}
|
||||
.database-mechanic-display-source > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
.database-mechanic-display-source dd {
|
||||
margin: 0;
|
||||
color: var(--shd-text-muted);
|
||||
font-size: var(--shd-type-telemetry);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.database-specialization-effect-groups {
|
||||
padding-top: 9px;
|
||||
border-top: 1px solid var(--shd-border);
|
||||
}
|
||||
.database-preview-dialog:has(.database-mechanic-groups) {
|
||||
width: min(760px, 100%);
|
||||
}
|
||||
.database-global-results > div > a {
|
||||
grid-template-columns: 110px minmax(0, 0.8fr) minmax(120px, 1.35fr) auto auto;
|
||||
grid-template-areas: 'category identity behavior status navigation';
|
||||
}
|
||||
.database-global-results > div > a > small {
|
||||
grid-area: category;
|
||||
}
|
||||
.database-global-results > div > a > .database-global-result-identity {
|
||||
grid-area: identity;
|
||||
}
|
||||
.database-global-results .database-global-result-behavior {
|
||||
display: block;
|
||||
grid-area: behavior;
|
||||
}
|
||||
.database-global-results .database-mechanic-status {
|
||||
grid-area: status;
|
||||
justify-self: end;
|
||||
}
|
||||
.database-global-results .database-global-result-navigation {
|
||||
grid-area: navigation;
|
||||
color: var(--shd-orange);
|
||||
}
|
||||
@media (max-width: 750px) {
|
||||
.database-item-name-line {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.database-item-name-line .database-mechanic-status {
|
||||
width: max-content;
|
||||
}
|
||||
.database-mechanic-variant {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.database-mechanic-variant-label {
|
||||
padding: 7px 10px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--shd-model-border);
|
||||
}
|
||||
.database-mechanic-variant[data-status='not_modeled'] .database-mechanic-variant-label {
|
||||
border-color: var(--shd-border);
|
||||
}
|
||||
.database-mechanic-group-identity {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.database-mechanic-group-identity .database-mechanic-status {
|
||||
width: max-content;
|
||||
}
|
||||
.database-global-results > div > a {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-areas:
|
||||
'category category'
|
||||
'identity navigation'
|
||||
'status navigation'
|
||||
'behavior behavior';
|
||||
}
|
||||
.database-global-results .database-mechanic-status {
|
||||
justify-self: start;
|
||||
}
|
||||
.database-global-results > div > a:not(:has(.database-mechanic-status)) {
|
||||
grid-template-areas:
|
||||
'category category'
|
||||
'identity navigation'
|
||||
'behavior behavior';
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.database-mechanic-status,
|
||||
.database-mechanic-variant {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,36 @@ import {
|
||||
equippedConditionalProgram,
|
||||
mechanicModifiersAt,
|
||||
skillModifierWindowsForMechanic,
|
||||
evaluateLoadoutCombat,
|
||||
weaponCriticalChance,
|
||||
} from './loadout-combat.js';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
type SkillAbilityInput,
|
||||
} from '$lib/calculations/skill';
|
||||
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||
|
||||
function selectedRuntime(
|
||||
variants: Record<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', () => {
|
||||
function magazineBandsFor(magazine: number, encounterSeconds = magazine - 1) {
|
||||
@@ -291,82 +315,568 @@ describe('loadout mechanic timeline', () => {
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('falls back to a documented Tier I mechanic when exotic Tier II is absent', () => {
|
||||
const persistedRule = {
|
||||
rule: {
|
||||
id: 'tier-i-only',
|
||||
source_type: 'talent',
|
||||
source_id: 'exotic-talent',
|
||||
configured: 1,
|
||||
script_version: 1,
|
||||
simulation_readiness: 'ready',
|
||||
scenario_inputs_json: '[]',
|
||||
},
|
||||
statePools: [],
|
||||
it('selects a direct validated Phase 2 program without adapting legacy rows', () => {
|
||||
const tierTwo: MechanicProgram = {
|
||||
id: 'mechanic-runtime:["source","II"]',
|
||||
name: 'Selected Tier II',
|
||||
clauses: [],
|
||||
};
|
||||
|
||||
expect(
|
||||
equippedConditionalProgram({
|
||||
key: 'weapon:0:talent',
|
||||
tier: 2,
|
||||
isExotic: false,
|
||||
runtime: selectedRuntime({ I: { ...tierTwo, id: 'tier-I' }, II: tierTwo }),
|
||||
}),
|
||||
).toBe(tierTwo);
|
||||
});
|
||||
|
||||
it('interpolates Skill Chip minimum and maximum programs from one selected revision', () => {
|
||||
const program = (id: string, value: number): MechanicProgram => ({
|
||||
id,
|
||||
name: 'Skill Chip',
|
||||
clauses: [
|
||||
{
|
||||
id: '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,
|
||||
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),
|
||||
});
|
||||
|
||||
const interpolated = equippedConditionalProgram({
|
||||
key: 'chips:chip-set:ability',
|
||||
tier: 2,
|
||||
isExotic: false,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(interpolated?.clauses[0].outcomes?.[0]?.value).toBe(30);
|
||||
expect(interpolated?.id).toBe('minimum');
|
||||
});
|
||||
|
||||
it('interpolates expression-backed outcomes, nested values, and pool durations', () => {
|
||||
const program = (id: string, value: number): MechanicProgram => ({
|
||||
id,
|
||||
name: 'Skill Chip',
|
||||
scenarioInputs: [
|
||||
{
|
||||
key: 'target-count',
|
||||
label: 'Target count',
|
||||
kind: 'slider',
|
||||
readiness: 'ready',
|
||||
defaultValue: value,
|
||||
bestCaseValue: value + 3,
|
||||
min: value - 2,
|
||||
max: value + 7,
|
||||
},
|
||||
],
|
||||
pools: [
|
||||
{
|
||||
key: 'chip-pool',
|
||||
label: 'Chip pool',
|
||||
scope: 'agent',
|
||||
initial: value,
|
||||
min: value - 2,
|
||||
max: value * 2,
|
||||
bestCaseValue: value + 2,
|
||||
expiresAfterSeconds: value - 1,
|
||||
},
|
||||
],
|
||||
clauses: [
|
||||
{
|
||||
id: 'chip-clause',
|
||||
when: 'weapon_hit',
|
||||
reducer: {
|
||||
kind: 'periodic',
|
||||
immediate: true,
|
||||
intervalSeconds: {
|
||||
op: 'clamp',
|
||||
value: {
|
||||
op: 'add',
|
||||
left: value,
|
||||
right: { op: 'floor', value: value + 1 },
|
||||
},
|
||||
min: value - 1,
|
||||
max: value * 3,
|
||||
},
|
||||
},
|
||||
cooldownSeconds: value,
|
||||
weight: {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: {
|
||||
op: 'subtract',
|
||||
left: { ref: { source: 'scenario', key: 'target-count' } },
|
||||
right: value - 1,
|
||||
},
|
||||
},
|
||||
mutations: [
|
||||
{
|
||||
kind: 'add',
|
||||
pool: 'chip-pool',
|
||||
value: {
|
||||
op: 'multiply',
|
||||
left: value,
|
||||
right: { op: 'add', left: value, right: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
outcomes: [
|
||||
{
|
||||
id: '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,
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
const adapted = equippedConditionalProgram({
|
||||
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,
|
||||
rule: persistedRule,
|
||||
runtime,
|
||||
}),
|
||||
).toBe(tierOne);
|
||||
expect(
|
||||
equippedConditionalProgram({
|
||||
key: 'weapon:0:standard-talent',
|
||||
tier: 2,
|
||||
isExotic: false,
|
||||
runtime,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
expect(adapted?.clauses[0].outcomes?.[0]).toMatchObject({
|
||||
id: 'weapon-damage-i',
|
||||
value: 15,
|
||||
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 MechanicOutcome,
|
||||
type MechanicProgram,
|
||||
type NumericExpression,
|
||||
} from '$lib/calculations/mechanic';
|
||||
import { adaptConditionalEffectProgram } from '$lib/calculations/mechanic-persistence';
|
||||
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||
import {
|
||||
calculateSkillDps,
|
||||
skillActivationTimes,
|
||||
@@ -192,103 +191,157 @@ export function weaponCriticalChance(
|
||||
);
|
||||
}
|
||||
|
||||
function conditionalProgram(
|
||||
rule: any,
|
||||
function selectedRuntimeProgram(
|
||||
runtime: MechanicRuntimeSelection | null | undefined,
|
||||
variantKey: string,
|
||||
valueScale = 1,
|
||||
): MechanicProgram | null {
|
||||
if (!rule?.rule) return null;
|
||||
const adapted = adaptConditionalEffectProgram(rule, { variantKey });
|
||||
if (
|
||||
!adapted.program ||
|
||||
adapted.status === 'INVALID' ||
|
||||
adapted.status === 'CAPTURED_ONLY'
|
||||
)
|
||||
return null;
|
||||
if (valueScale === 1) return adapted.program;
|
||||
const scale = (value: NumericExpression): NumericExpression =>
|
||||
typeof value === 'number' ? value * valueScale : value;
|
||||
return {
|
||||
...adapted.program,
|
||||
clauses: adapted.program.clauses.map((clause) => ({
|
||||
...clause,
|
||||
outcomes: clause.outcomes?.map((outcome) => ({
|
||||
...outcome,
|
||||
value: scale(outcome.value),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
return runtime?.variants.find((variant) => variant.key === variantKey)?.program ?? null;
|
||||
}
|
||||
|
||||
function interpolatedConditionalProgram(
|
||||
rule: any,
|
||||
function hasMechanicProgram<T extends { program: MechanicProgram | null }>(
|
||||
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,
|
||||
maximumVariant: string,
|
||||
step: number,
|
||||
totalSteps: number,
|
||||
): MechanicProgram | null {
|
||||
const minimum = conditionalProgram(rule, minimumVariant);
|
||||
const maximum = conditionalProgram(rule, maximumVariant);
|
||||
if (!minimum || !maximum) return minimum ?? maximum;
|
||||
const minimum = selectedRuntimeProgram(runtime, minimumVariant);
|
||||
const maximum = selectedRuntimeProgram(runtime, maximumVariant);
|
||||
if (!minimum || !maximum) return null;
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, Number(step) / Math.max(1, Number(totalSteps))),
|
||||
);
|
||||
const maximumByKey = new Map(
|
||||
maximum.clauses.flatMap((clause) =>
|
||||
(clause.outcomes ?? []).map(
|
||||
(outcome) => [`${clause.id}:${outcome.id}`, outcome] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
...minimum,
|
||||
clauses: minimum.clauses.map((clause) => ({
|
||||
...clause,
|
||||
outcomes: clause.outcomes?.map((outcome) => {
|
||||
const upper = maximumByKey.get(`${clause.id}:${outcome.id}`);
|
||||
return upper &&
|
||||
typeof outcome.value === 'number' &&
|
||||
typeof upper.value === 'number'
|
||||
? {
|
||||
...outcome,
|
||||
value: outcome.value + (upper.value - outcome.value) * ratio,
|
||||
}
|
||||
: outcome;
|
||||
}),
|
||||
})),
|
||||
};
|
||||
if (!Number.isFinite(ratio)) return null;
|
||||
const interpolated = interpolateMechanicProgram(minimum, maximum, ratio);
|
||||
if (!interpolated) return null;
|
||||
if (ratio === 0) return minimum;
|
||||
if (ratio === 1) return maximum;
|
||||
return interpolated;
|
||||
}
|
||||
|
||||
export function equippedConditionalProgram(
|
||||
equipped: any,
|
||||
equipped: Pick<
|
||||
LoadoutSummary['equippedTalents'][number],
|
||||
'key' | 'runtime' | 'tier' | 'isExotic'
|
||||
>,
|
||||
): MechanicProgram | null {
|
||||
if (String(equipped.key).startsWith('chips:')) {
|
||||
return interpolatedConditionalProgram(
|
||||
equipped.rule,
|
||||
if (equipped.key.startsWith('chips:')) {
|
||||
return interpolatedRuntimeProgram(
|
||||
equipped.runtime,
|
||||
'minimum',
|
||||
'maximum',
|
||||
Number(equipped.tier),
|
||||
3,
|
||||
);
|
||||
}
|
||||
if (String(equipped.key).startsWith('protocol:')) {
|
||||
return conditionalProgram(equipped.rule, 'base');
|
||||
if (equipped.key.startsWith('protocol:')) {
|
||||
return selectedRuntimeProgram(equipped.runtime, 'base');
|
||||
}
|
||||
const equipmentTalent =
|
||||
String(equipped.key).startsWith('weapon:') ||
|
||||
String(equipped.key).startsWith('gear:');
|
||||
const variant = equipmentTalent
|
||||
? Number(equipped.tier) >= 2
|
||||
? 'II'
|
||||
: 'I'
|
||||
: 'base';
|
||||
return (
|
||||
conditionalProgram(equipped.rule, variant) ??
|
||||
equipped.key.startsWith('weapon:') || equipped.key.startsWith('gear:');
|
||||
const variant = equipmentTalent && Number(equipped.tier) >= 2 ? 'II' : 'I';
|
||||
const selected = selectedRuntimeProgram(equipped.runtime, variant);
|
||||
return selected ??
|
||||
(equipmentTalent && equipped.isExotic && variant === 'II'
|
||||
? conditionalProgram(equipped.rule, 'I')
|
||||
: null)
|
||||
);
|
||||
? selectedRuntimeProgram(equipped.runtime, 'I')
|
||||
: null);
|
||||
}
|
||||
|
||||
export type ActiveMechanicEvaluation = {
|
||||
@@ -299,15 +352,58 @@ export type ActiveMechanicEvaluation = {
|
||||
events?: MechanicEvent[];
|
||||
};
|
||||
|
||||
function configuredScenarioInputValues(configured: unknown) {
|
||||
return configured && typeof configured === 'object' && !Array.isArray(configured)
|
||||
? (configured as Record<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(
|
||||
program: MechanicProgram,
|
||||
configured: unknown,
|
||||
maximum = false,
|
||||
) {
|
||||
const values =
|
||||
configured && typeof configured === 'object' && !Array.isArray(configured)
|
||||
? (configured as Record<string, number | boolean>)
|
||||
: {};
|
||||
): Record<string, number | boolean> {
|
||||
const values = configuredScenarioInputValues(configured) as Record<
|
||||
string,
|
||||
number | boolean
|
||||
>;
|
||||
return Object.fromEntries(
|
||||
(program.scenarioInputs ?? []).map((input) => [
|
||||
input.key,
|
||||
@@ -322,22 +418,59 @@ function mechanicScenarioInputs(
|
||||
);
|
||||
}
|
||||
|
||||
function mechanicIsEnabled(
|
||||
state: Pick<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(
|
||||
program: MechanicProgram,
|
||||
buildEvents: (
|
||||
scenarioInputs: Record<string, number | boolean>,
|
||||
) => MechanicEvent[],
|
||||
state: any,
|
||||
state: LoadoutDraft['conditionalEffects'][string],
|
||||
profile: LoadoutDraft['combatProfile'],
|
||||
weaponBaseDamage: number,
|
||||
maxHealth: number,
|
||||
calculationMode: LoadoutDraft['calculationMode'],
|
||||
equipmentContext = { protocolTalentUpgrades: 0 },
|
||||
attributes: Record<string, number> = {},
|
||||
simulationReadiness: MechanicRuntimeSelection['simulationReadiness'] = 'ready',
|
||||
): ActiveMechanicEvaluation | null {
|
||||
if (calculationMode !== 'maximum' && state.mode === 'off') return null;
|
||||
if (!mechanicIsEnabled(state, calculationMode)) return null;
|
||||
if (unresolvedScenarioInputs(program, state.inputs, simulationReadiness).length)
|
||||
return null;
|
||||
const encounterSeconds = Math.max(1, Number(profile.encounterSeconds));
|
||||
const maximum = calculationMode === 'maximum' || state.mode === 'peak';
|
||||
const maximum =
|
||||
simulationReadiness !== 'requires_assumptions' &&
|
||||
(calculationMode === 'maximum' || state.mode === 'peak');
|
||||
const scenarioInputs = mechanicScenarioInputs(program, state.inputs, maximum);
|
||||
const events = buildEvents(scenarioInputs);
|
||||
const result = evaluateMechanicProgram(
|
||||
@@ -1045,7 +1178,7 @@ function skillModifierWindows(
|
||||
catalog: Catalog,
|
||||
summary: LoadoutSummary,
|
||||
allowedAttributeIds: Set<string>,
|
||||
): SkillModifierWindow[] {
|
||||
): { windows: SkillModifierWindow[]; unavailable: string[] } {
|
||||
const focus = selectedFocus(draft, catalog);
|
||||
const combatEffects = selectedNodeRanks(draft, catalog).flatMap(
|
||||
({ node, rank }: any) =>
|
||||
@@ -1058,21 +1191,25 @@ function skillModifierWindows(
|
||||
const sources = [
|
||||
...summary.equippedTalents.map((equipped: any) => ({
|
||||
key: String(equipped.key),
|
||||
name: String(equipped.custom?.name ?? 'Talent'),
|
||||
abilityId: equipped.abilityId ? String(equipped.abilityId) : null,
|
||||
equipmentContext: {
|
||||
protocolTalentUpgrades: String(equipped.key).startsWith('protocol:')
|
||||
? Number(equipped.tier)
|
||||
: 0,
|
||||
},
|
||||
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
|
||||
program: equippedConditionalProgram(equipped),
|
||||
})),
|
||||
...combatEffects.map((effect: any) => ({
|
||||
key: String(effect.id),
|
||||
name: String(effect.definition?.name ?? effect.nodeName ?? 'Specialization effect'),
|
||||
abilityId: effect.abilityId,
|
||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
||||
program: conditionalProgram(effect.rule, 'base'),
|
||||
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
|
||||
program: selectedRuntimeProgram(effect.runtime, 'base'),
|
||||
})),
|
||||
].filter((source) => source.program);
|
||||
].filter(hasMechanicProgram);
|
||||
const primarySelection = draft.weapons[0];
|
||||
const primaryWeapon = catalog.weapons.find(
|
||||
(weapon) => weapon.id === primarySelection.itemId,
|
||||
@@ -1123,6 +1260,7 @@ function skillModifierWindows(
|
||||
draft.calculationMode,
|
||||
source.equipmentContext,
|
||||
baseAttributes,
|
||||
source.simulationReadiness,
|
||||
);
|
||||
return evaluation ? [{ ...source, evaluation }] : [];
|
||||
});
|
||||
@@ -1138,7 +1276,10 @@ function skillModifierWindows(
|
||||
);
|
||||
}
|
||||
|
||||
return windows;
|
||||
return {
|
||||
windows,
|
||||
unavailable: unavailableMechanicEvidence(sources, draft),
|
||||
};
|
||||
}
|
||||
|
||||
export function evaluateLoadoutCombat(
|
||||
@@ -1166,6 +1307,7 @@ export function evaluateLoadoutCombat(
|
||||
? Number(equipped.tier)
|
||||
: 0,
|
||||
},
|
||||
simulationReadiness: equipped.runtime?.simulationReadiness ?? 'ready',
|
||||
program: equippedConditionalProgram(equipped),
|
||||
})),
|
||||
...combatEffects.map((effect: any) => ({
|
||||
@@ -1174,9 +1316,10 @@ export function evaluateLoadoutCombat(
|
||||
effect.definition?.name ?? effect.nodeName ?? 'Specialization effect',
|
||||
),
|
||||
equipmentContext: { protocolTalentUpgrades: 0 },
|
||||
program: conditionalProgram(effect.rule, 'base'),
|
||||
simulationReadiness: effect.runtime?.simulationReadiness ?? 'ready',
|
||||
program: selectedRuntimeProgram(effect.runtime, 'base'),
|
||||
})),
|
||||
].filter((source) => source.program);
|
||||
].filter(hasMechanicProgram);
|
||||
const skillDeploymentEvents = loadoutSkillDeploymentEvents(
|
||||
draft,
|
||||
catalog,
|
||||
@@ -1201,6 +1344,7 @@ export function evaluateLoadoutCombat(
|
||||
missing.push(
|
||||
`${missingChips} specialization Skill Chip${missingChips === 1 ? '' : 's'}`,
|
||||
);
|
||||
missing.push(...unavailableMechanicEvidence(conditionalSources, draft));
|
||||
return missing;
|
||||
};
|
||||
|
||||
@@ -1251,6 +1395,7 @@ export function evaluateLoadoutCombat(
|
||||
draft.calculationMode,
|
||||
source.equipmentContext,
|
||||
criticalAttributes,
|
||||
source.simulationReadiness,
|
||||
);
|
||||
return evaluation ? [{ ...source, state, evaluation }] : [];
|
||||
});
|
||||
@@ -1418,6 +1563,21 @@ export function evaluateLoadoutSkillCombat(
|
||||
.map((stat) => [stat.id, stat.value]),
|
||||
);
|
||||
const abilities = loadoutSkillAbilities(draft, catalog);
|
||||
const mechanicWindows = skillModifierWindows(
|
||||
draft,
|
||||
catalog,
|
||||
summary,
|
||||
boundIds,
|
||||
);
|
||||
if (mechanicWindows.unavailable.length) {
|
||||
return {
|
||||
status: 'INSUFFICIENT_DATA',
|
||||
revisionId: revisionId || null,
|
||||
missing: mechanicWindows.unavailable,
|
||||
abilities: [],
|
||||
combined: null,
|
||||
};
|
||||
}
|
||||
return calculateSkillDps({
|
||||
rules: { revisionId, bindings },
|
||||
abilities,
|
||||
@@ -1440,7 +1600,7 @@ export function evaluateLoadoutSkillCombat(
|
||||
];
|
||||
}),
|
||||
),
|
||||
modifierWindows: skillModifierWindows(draft, catalog, summary, boundIds),
|
||||
modifierWindows: mechanicWindows.windows,
|
||||
assumptions: {
|
||||
encounterSeconds: draft.combatProfile.encounterSeconds,
|
||||
targetCount: Math.max(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||
import { createDraft } from './loadout.js';
|
||||
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
|
||||
|
||||
@@ -35,6 +36,24 @@ const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
|
||||
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
|
||||
summary.stats.find((entry) => entry.id === id);
|
||||
|
||||
const selectedRuntime = (
|
||||
sourceId: string,
|
||||
variantKeys: string[],
|
||||
): MechanicRuntimeSelection => ({
|
||||
owner: { type: 'talent', id: sourceId, scope: 'standard', hasTierII: true },
|
||||
sourceId,
|
||||
sourceFingerprint: 'a'.repeat(64),
|
||||
revisionId: `${sourceId}-revision`,
|
||||
revisionNumber: 1,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [],
|
||||
variants: variantKeys.map((key) => ({
|
||||
key,
|
||||
label: key,
|
||||
program: { id: `${sourceId}:${key}`, name: sourceId, clauses: [] },
|
||||
})),
|
||||
});
|
||||
|
||||
describe('summarizeLoadout', () => {
|
||||
it('returns an empty deterministic summary for a partial draft', () => {
|
||||
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
|
||||
@@ -193,7 +212,6 @@ describe('summarizeLoadout', () => {
|
||||
max_value: 45,
|
||||
},
|
||||
],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -399,7 +417,6 @@ describe('summarizeLoadout', () => {
|
||||
name: 'Recovery',
|
||||
description_template: 'Restore {1}.',
|
||||
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
|
||||
rule: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -536,19 +553,6 @@ describe('summarizeLoadout', () => {
|
||||
exoticTalentTier: 1,
|
||||
});
|
||||
|
||||
const rule = {
|
||||
clauses: [
|
||||
{
|
||||
outcomes: [
|
||||
{
|
||||
kind: 'attribute_modifier',
|
||||
attributeId: 'talent-stat',
|
||||
value: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
@@ -585,7 +589,6 @@ describe('summarizeLoadout', () => {
|
||||
],
|
||||
},
|
||||
],
|
||||
rule,
|
||||
},
|
||||
{
|
||||
id: 'talent-two',
|
||||
@@ -627,7 +630,7 @@ describe('summarizeLoadout', () => {
|
||||
tier: 1,
|
||||
tierLabel: 'TIER I',
|
||||
description: 'Gain 10% for 2 Shots.',
|
||||
rule,
|
||||
runtime: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'talent-two',
|
||||
@@ -645,6 +648,69 @@ describe('summarizeLoadout', () => {
|
||||
expect(stat(summary, 'talent-stat')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries selected runtime capabilities without retaining legacy rule payloads', () => {
|
||||
const draft = createDraft();
|
||||
draft.gear.Holster.itemId = 'gear';
|
||||
draft.gear.Holster.talentId = 'talent';
|
||||
for (const chip of draft.chips.slice(0, 3)) chip.setId = 'chip-set';
|
||||
draft.protocol.itemId = 'protocol';
|
||||
|
||||
const talentRuntime = selectedRuntime('talent-source', ['I', 'II']);
|
||||
const chipRuntime = selectedRuntime('chip-source', ['minimum', 'maximum']);
|
||||
const protocolRuntime = selectedRuntime('protocol-source', ['base']);
|
||||
const summary = summarizeLoadout(
|
||||
draft,
|
||||
catalog({
|
||||
gear: [{ id: 'gear', tier: 0, base_armor_min: null }],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent',
|
||||
name: 'Talent',
|
||||
runtime: talentRuntime,
|
||||
template: 'Talent',
|
||||
tiers: [{ tier: 'I', effects: [] }],
|
||||
},
|
||||
],
|
||||
chipSets: [
|
||||
{
|
||||
id: 'chip-set',
|
||||
name: 'Chip',
|
||||
specialization_ability_id: 'ability',
|
||||
talent: {
|
||||
name: 'Chip Talent',
|
||||
runtime: chipRuntime,
|
||||
description_template: 'Chip',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
protocols: [
|
||||
{
|
||||
id: 'protocol',
|
||||
name: 'Protocol',
|
||||
talent: {
|
||||
name: 'Protocol Talent',
|
||||
runtime: protocolRuntime,
|
||||
description_template: 'Protocol',
|
||||
effects: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.equippedTalents.map((equipped) => equipped.runtime)).toEqual([
|
||||
talentRuntime,
|
||||
chipRuntime,
|
||||
protocolRuntime,
|
||||
]);
|
||||
expect(
|
||||
summary.equippedTalents.every(
|
||||
(equipped) => !Object.hasOwn(equipped, 'rule'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the authored tier description when a Talent has no template row', () => {
|
||||
const draft = createDraft();
|
||||
draft.weapons[0].itemId = 'weapon';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type LoadoutDraft,
|
||||
type TalentTier,
|
||||
} from '$lib/builder/loadout';
|
||||
import type { MechanicRuntimeSelection } from '$lib/server/mechanic-runtime-selection';
|
||||
|
||||
export type LoadoutCatalog = {
|
||||
attributes: any[];
|
||||
@@ -38,7 +39,18 @@ export type LoadoutSummary = {
|
||||
}>;
|
||||
}>;
|
||||
activeBonuses: any[];
|
||||
equippedTalents: any[];
|
||||
equippedTalents: Array<{
|
||||
key: string;
|
||||
id: string;
|
||||
runtime: MechanicRuntimeSelection | null;
|
||||
tier: number;
|
||||
tierLabel?: string;
|
||||
isExotic?: boolean;
|
||||
abilityId?: string | null;
|
||||
source: string;
|
||||
description: string;
|
||||
custom?: { name?: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
|
||||
@@ -164,7 +176,7 @@ export function summarizeLoadout(
|
||||
)?.id ?? '';
|
||||
const brandCounts = new Map<string, number>();
|
||||
const activeBonuses: any[] = [];
|
||||
const equippedTalents: any[] = [];
|
||||
const equippedTalents: LoadoutSummary['equippedTalents'] = [];
|
||||
const abilityValues = new Map<string, Map<string, number>>();
|
||||
const abilitySources = new Map<
|
||||
string,
|
||||
@@ -260,9 +272,9 @@ export function summarizeLoadout(
|
||||
equippedTalents.push({
|
||||
key: `gear:${slot}:${selection.talentId}`,
|
||||
id: selection.talentId,
|
||||
rule:
|
||||
runtime:
|
||||
catalog.talents.find((entry) => entry.id === selection.talentId)
|
||||
?.rule ?? null,
|
||||
?.runtime ?? null,
|
||||
tier: selection.talentTier,
|
||||
tierLabel: selection.talentTier === 2 ? 'TIER II' : 'TIER I',
|
||||
isExotic: false,
|
||||
@@ -336,9 +348,9 @@ export function summarizeLoadout(
|
||||
equippedTalents.push({
|
||||
key: `weapon:${index}:${equippedTalent.id}`,
|
||||
id: equippedTalent.id,
|
||||
rule:
|
||||
runtime:
|
||||
catalog.talents.find((entry) => entry.id === equippedTalent.id)
|
||||
?.rule ?? null,
|
||||
?.runtime ?? null,
|
||||
tier: equippedTalent.tier,
|
||||
tierLabel: equippedTalent.tier === 2 ? 'TIER II' : 'TIER I',
|
||||
isExotic: equippedTalent.isExotic,
|
||||
@@ -434,7 +446,7 @@ export function summarizeLoadout(
|
||||
key: `chips:${group.item.id}:${group.abilityId}`,
|
||||
id: '',
|
||||
custom: group.item.talent,
|
||||
rule: group.item.talent.rule ?? null,
|
||||
runtime: group.item.talent.runtime ?? null,
|
||||
tier: group.highEnd,
|
||||
abilityId: group.abilityId || null,
|
||||
source: '3 Skill Chips',
|
||||
@@ -486,7 +498,7 @@ export function summarizeLoadout(
|
||||
key: `protocol:${protocol.id}`,
|
||||
id: '',
|
||||
custom: protocol.talent,
|
||||
rule: protocol.talent.rule ?? null,
|
||||
runtime: protocol.talent.runtime ?? null,
|
||||
tier: draft.protocol.talentUpgrades,
|
||||
source: 'OS Protocol',
|
||||
description: protocol.talent.description_template.replace(
|
||||
|
||||
@@ -7,15 +7,20 @@
|
||||
import type { DatabaseItem } from '$lib/database/items';
|
||||
import MarkdownContent from './MarkdownContent.svelte';
|
||||
import WeaponModSlotIcon from './WeaponModSlotIcon.svelte';
|
||||
import DatabaseMechanicGroupCard from './database/DatabaseMechanicGroup.svelte';
|
||||
import DatabaseMechanicStatusBadge from './database/DatabaseMechanicStatusBadge.svelte';
|
||||
import { databaseMechanicGroupStatus, type DatabaseMechanicGroup } from '$lib/database/items';
|
||||
|
||||
let {
|
||||
item,
|
||||
hideDiscussionUtility = false,
|
||||
anchor = true
|
||||
anchor = true,
|
||||
detail = false
|
||||
}: {
|
||||
item: DatabaseItem & { gameIcon?: GameIconName };
|
||||
hideDiscussionUtility?: boolean;
|
||||
anchor?: boolean;
|
||||
detail?: boolean;
|
||||
} = $props();
|
||||
let protocolLevel = $state(1);
|
||||
let talentUpgrades = $state(0);
|
||||
@@ -29,6 +34,14 @@
|
||||
const effect = item.protocolPreview?.talentEffects[Number(raw) - 1];
|
||||
return effect ? valueWithUnit(effect.baseValue * (1 + talentUpgrades / 5), effect.unit) : placeholder;
|
||||
});
|
||||
const mechanicGroups = () => item.mechanicGroups ?? [];
|
||||
const hasMechanicGroups = () => mechanicGroups().length > 0;
|
||||
const isSpecializationMechanic = (group: DatabaseMechanicGroup) => group.ownerType === 'specialization_tree_rank';
|
||||
const hasSpecializationMechanics = () => mechanicGroups().some(isSpecializationMechanic);
|
||||
const primaryMechanicGroup = () => {
|
||||
const groups = mechanicGroups();
|
||||
return groups.length === 1 && !isSpecializationMechanic(groups[0]) ? groups[0] : null;
|
||||
};
|
||||
</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'}>
|
||||
@@ -39,7 +52,12 @@
|
||||
</div>
|
||||
<div class="database-item-identity">
|
||||
<small>{item.kicker}</small>
|
||||
<div class="database-item-name-line">
|
||||
<h2>{item.name}</h2>
|
||||
{#if primaryMechanicGroup()}
|
||||
<DatabaseMechanicStatusBadge status={databaseMechanicGroupStatus(primaryMechanicGroup()!)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if item.headerBadges?.length || item.headerBadge || item.headerMetaIcon}
|
||||
<div class="database-header-status">
|
||||
@@ -53,7 +71,7 @@
|
||||
{#if item.protocolPreview}
|
||||
<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>
|
||||
{#if item.protocolPreview.talentDescription}
|
||||
{#if item.protocolPreview.talentDescription && !hasMechanicGroups()}
|
||||
<div class="database-protocol-talent"><p>{talentDescription()}</p></div>
|
||||
{/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>
|
||||
@@ -86,15 +104,29 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/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">
|
||||
<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>
|
||||
{: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.secondaryDescription}<p class="secondary-description" class:data-description={item.descriptionStyle === 'data'}>{item.secondaryDescription}</p>{/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}
|
||||
<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}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||
let dialog = $state<HTMLDivElement | null>(null);
|
||||
const openPreview = async (item: DatabaseRelatedItem, button: HTMLButtonElement) => {
|
||||
if (!item.preview) return;
|
||||
selected = item;
|
||||
trigger = button;
|
||||
await tick();
|
||||
@@ -32,8 +33,8 @@
|
||||
};
|
||||
const trapDialogFocus = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab' || !dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),[tabindex]:not([tabindex="-1"])')]
|
||||
.filter((element) => !element.hasAttribute('hidden'));
|
||||
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') && element.getClientRects().length > 0);
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
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-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>
|
||||
<DatabaseItemCard item={selected.preview}/>
|
||||
<DatabaseItemCard item={selected.preview} anchor={false} detail/>
|
||||
</div>
|
||||
</div>
|
||||
{/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';
|
||||
|
||||
export type DatabaseBadge = { label: string; tone?: string };
|
||||
|
||||
/** Public, serializable mechanic presentation for one source owner. */
|
||||
export type DatabaseMechanicOwnerType =
|
||||
| 'talent'
|
||||
| 'os_protocol_talent'
|
||||
| 'skill_chip_talent'
|
||||
| 'specialization_tree_rank';
|
||||
export type DatabaseMechanicStatus = 'modeled' | 'not_modeled';
|
||||
export type DatabaseMechanicDisplaySource = 'model' | 'authored';
|
||||
export type DatabaseMechanicVariant = {
|
||||
label: string;
|
||||
behaviorText: string;
|
||||
status: DatabaseMechanicStatus;
|
||||
displaySource: DatabaseMechanicDisplaySource;
|
||||
};
|
||||
export type DatabaseMechanicGroup = {
|
||||
/** Stable owner identity for keyed rendering and deep-linkable detail. */
|
||||
id: string;
|
||||
ownerType: DatabaseMechanicOwnerType;
|
||||
label: string;
|
||||
context?: string;
|
||||
variants: DatabaseMechanicVariant[];
|
||||
};
|
||||
|
||||
export const databaseMechanicStatusLabel = (status: DatabaseMechanicStatus) =>
|
||||
status === 'modeled' ? 'MODELED' : 'NOT MODELED';
|
||||
|
||||
/** A complete public owner projection has one status across its variants. */
|
||||
export const databaseMechanicGroupStatus = (
|
||||
group: DatabaseMechanicGroup,
|
||||
): DatabaseMechanicStatus =>
|
||||
group.variants.length > 0 &&
|
||||
group.variants.every((variant) => variant.status === 'modeled')
|
||||
? 'modeled'
|
||||
: 'not_modeled';
|
||||
|
||||
export const databaseMechanicGroupDisplaySourceLabel = (
|
||||
group: DatabaseMechanicGroup,
|
||||
) =>
|
||||
databaseMechanicGroupStatus(group) === 'modeled'
|
||||
? 'MODELED BEHAVIOR'
|
||||
: 'AUTHORED DESCRIPTION';
|
||||
|
||||
export const isDatabaseMechanicStatus = (
|
||||
value: unknown,
|
||||
): value is DatabaseMechanicStatus =>
|
||||
value === 'modeled' || value === 'not_modeled';
|
||||
export type DatabaseMeter = { label: string; value: number };
|
||||
export type DatabaseWeaponModSlot = {
|
||||
slot: string;
|
||||
@@ -43,6 +90,7 @@ export type DatabaseItem = {
|
||||
afterLines?: DatabaseFact[];
|
||||
iconGroups?: DatabaseIconGroup[];
|
||||
weaponModSlots?: DatabaseWeaponModSlot[];
|
||||
mechanicGroups?: DatabaseMechanicGroup[];
|
||||
description?: string;
|
||||
secondaryDescription?: string;
|
||||
descriptionStyle?: 'prose' | 'data';
|
||||
|
||||
@@ -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,
|
||||
} from '$lib/calculations/mechanic-public-renderer';
|
||||
import type { Database } from '@tursodatabase/database';
|
||||
import type { MechanicAuthoredOwner } from '$lib/calculations/mechanic-authored-description';
|
||||
import {
|
||||
loadMechanicAuthoredSource,
|
||||
loadMechanicAuthoredSourceById,
|
||||
type MechanicAuthoredSourceRecord,
|
||||
} from './mechanic-authored-sources';
|
||||
@@ -37,6 +39,50 @@ export type MechanicPublicProjection = {
|
||||
variants: MechanicPublicVariantRecord[];
|
||||
};
|
||||
|
||||
/** Stable public identity for a mechanic owner, without authoring-only fields. */
|
||||
export type MechanicPublicProjectionOwnerRef = {
|
||||
[Type in MechanicAuthoredOwner['type']]: Pick<
|
||||
Extract<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. */
|
||||
export async function refreshMechanicPublicProjectionInTransaction(
|
||||
database: Database,
|
||||
@@ -146,6 +192,356 @@ function isValidTimestamp(value: unknown): value is string {
|
||||
return isNonEmptyString(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
type MechanicPublicProjectionSnapshotRow = Record<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
|
||||
* 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(
|
||||
selected: MechanicModelRevisionRecord | null,
|
||||
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 { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
import { loadBuildMechanicRuntimes } from './build-mechanic-runtime';
|
||||
|
||||
export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
const [
|
||||
@@ -37,6 +38,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationRankCombatEffects,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
@@ -172,9 +174,35 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT attachment.*,effect.name AS definition_name
|
||||
FROM specialization_tree_rank_combat_effects attachment
|
||||
JOIN specialization_tree_node_ranks rank
|
||||
ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank
|
||||
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
|
||||
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
|
||||
JOIN specializations specialization
|
||||
ON specialization.id=focus.specialization_id AND specialization.active=1
|
||||
JOIN specialization_combat_effects effect
|
||||
ON effect.id=attachment.combat_effect_id
|
||||
AND effect.specialization_id=focus.specialization_id
|
||||
AND effect.active=1
|
||||
ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE,
|
||||
node.row_number,node.column_number,attachment.rank,
|
||||
attachment.effect_order,attachment.id`)
|
||||
).all(),
|
||||
]);
|
||||
const mechanics = await loadBuildMechanicRuntimes(database, {
|
||||
talentIds: talents.map((talent) => String(talent.id)),
|
||||
protocolTalentIds: protocolTalents.map((talent) => String(talent.id)),
|
||||
skillChipTalentIds: chipTalents.map((talent) => String(talent.id)),
|
||||
specializationAttachmentIds: specializationRankCombatEffects.map((effect) =>
|
||||
String(effect.id),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
mechanics,
|
||||
attributes,
|
||||
gear,
|
||||
gearAugments,
|
||||
@@ -187,6 +215,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
|
||||
talents: talents.map((talent) => ({
|
||||
...talent,
|
||||
runtime: mechanics.talents[String(talent.id)] ?? null,
|
||||
template:
|
||||
talentTemplates.find((item) => item.talent_id === talent.id)
|
||||
?.template ?? '',
|
||||
@@ -231,6 +260,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
@@ -260,6 +290,7 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
@@ -298,6 +329,23 @@ export async function loadSharedBuildSummaryCatalog(database: Database) {
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: {
|
||||
id: String(effect.combat_effect_id),
|
||||
name: String(effect.definition_name),
|
||||
},
|
||||
runtime:
|
||||
mechanics.specializationAttachments[
|
||||
String(effect.id)
|
||||
] ?? null,
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createDraft } from '$lib/builder/loadout';
|
||||
const mocks = vi.hoisted(() => ({
|
||||
row: null as Record<string, unknown> | null,
|
||||
loadCatalog: vi.fn(),
|
||||
summarizeLoadout: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
@@ -14,12 +15,101 @@ vi.mock('$lib/server/database', () => ({
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
vi.mock('$lib/builder/loadout-summary', () => ({
|
||||
summarizeLoadout: mocks.summarizeLoadout,
|
||||
}));
|
||||
vi.mock('$lib/server/shared-build-summary', () => ({
|
||||
loadSharedBuildSummaryCatalog: mocks.loadCatalog,
|
||||
}));
|
||||
|
||||
import { loadSharedBuildProjection } from './shared-build';
|
||||
|
||||
const selectedRuntime = {
|
||||
owner: { type: 'talent', id: 'talent-1', scope: 'standard' },
|
||||
sourceId: 'talent-source',
|
||||
sourceFingerprint: 'f'.repeat(64),
|
||||
revisionId: 'talent-revision',
|
||||
revisionNumber: 4,
|
||||
simulationReadiness: 'ready',
|
||||
scenarioInputs: [
|
||||
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
key: 'I',
|
||||
label: 'Tier I',
|
||||
program: {
|
||||
id: 'mechanic-runtime:["talent-source","I"]',
|
||||
name: 'Selected mechanic',
|
||||
scenarioInputs: [
|
||||
{ key: 'nearby-enemies', label: 'Nearby enemies', kind: 'number' },
|
||||
],
|
||||
pools: [{ key: 'stacks', label: 'Stacks', min: 0, max: 10 }],
|
||||
clauses: [
|
||||
{
|
||||
key: 'on-kill',
|
||||
outcomes: [
|
||||
{
|
||||
key: 'damage',
|
||||
variants: { I: { value: 15 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mechanics = {
|
||||
talents: { 'talent-1': selectedRuntime },
|
||||
protocolTalents: { 'protocol-talent-1': selectedRuntime },
|
||||
skillChipTalents: { 'chip-talent-1': selectedRuntime },
|
||||
specializationAttachments: { 'attachment-1': selectedRuntime },
|
||||
};
|
||||
|
||||
const mechanicInternalKeys = new Set([
|
||||
'mechanics',
|
||||
'runtime',
|
||||
'sourceId',
|
||||
'sourceFingerprint',
|
||||
'revisionId',
|
||||
'revisionNumber',
|
||||
'simulationReadiness',
|
||||
'variants',
|
||||
'key',
|
||||
'program',
|
||||
'scenarioInputs',
|
||||
'pools',
|
||||
'clauses',
|
||||
'outcomes',
|
||||
'programHash',
|
||||
'mechanicContractVersion',
|
||||
'selectedRevisionId',
|
||||
'source_id',
|
||||
'source_fingerprint',
|
||||
'revision_id',
|
||||
'revision_number',
|
||||
'simulation_readiness',
|
||||
'scenario_inputs',
|
||||
'program_hash',
|
||||
'mechanic_contract_version',
|
||||
'selected_revision_id',
|
||||
]);
|
||||
|
||||
function serializedMechanicInternals(value: unknown, path = '$'): string[] {
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((entry, index) =>
|
||||
serializedMechanicInternals(entry, `${path}[${index}]`),
|
||||
);
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
|
||||
return Object.entries(value).flatMap(([key, entry]) => [
|
||||
...(mechanicInternalKeys.has(key) ? [`${path}.${key}`] : []),
|
||||
...serializedMechanicInternals(entry, `${path}.${key}`),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildRow(visibility: 'private' | 'unlisted' | 'public') {
|
||||
return {
|
||||
id: 'build-id',
|
||||
@@ -37,16 +127,101 @@ function buildRow(visibility: 'private' | 'unlisted' | 'public') {
|
||||
describe('shared Build access policy', () => {
|
||||
beforeEach(() => {
|
||||
mocks.row = null;
|
||||
mocks.summarizeLoadout.mockReset().mockReturnValue({
|
||||
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
|
||||
abilityStats: [
|
||||
{
|
||||
abilityId: 'ability-1',
|
||||
stats: [
|
||||
{
|
||||
id: 'skill-power',
|
||||
value: 18,
|
||||
sourceCount: 1,
|
||||
sources: [
|
||||
{
|
||||
source: 'Skill Chip 1 primary',
|
||||
chipIndex: 0,
|
||||
chipSetId: 'chip-set-1',
|
||||
value: 18,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeBonuses: [
|
||||
{
|
||||
name: 'Medic',
|
||||
pieces: 2,
|
||||
count: 3,
|
||||
statId: 'skill-power',
|
||||
value: 10,
|
||||
runtime: selectedRuntime,
|
||||
},
|
||||
],
|
||||
equippedTalents: [
|
||||
{
|
||||
key: 'gear:Mask:talent-1',
|
||||
id: 'talent-1',
|
||||
runtime: selectedRuntime,
|
||||
tier: 2,
|
||||
tierLabel: 'TIER II',
|
||||
source: 'Mask',
|
||||
description: 'Restore 15% Armor.',
|
||||
custom: {
|
||||
name: 'Medic Protocol',
|
||||
runtime: selectedRuntime,
|
||||
},
|
||||
},
|
||||
],
|
||||
runtime: selectedRuntime,
|
||||
});
|
||||
mocks.loadCatalog.mockReset().mockResolvedValue({
|
||||
attributes: [],
|
||||
attributes: [
|
||||
{
|
||||
id: 'firepower',
|
||||
name: 'Firepower',
|
||||
unit: '%',
|
||||
category: 'Core',
|
||||
runtime: selectedRuntime,
|
||||
},
|
||||
],
|
||||
gear: [],
|
||||
gearAugments: [],
|
||||
brands: [],
|
||||
weapons: [],
|
||||
talents: [],
|
||||
chipSets: [],
|
||||
protocols: [],
|
||||
specializations: [],
|
||||
talents: [
|
||||
{
|
||||
id: 'talent-1',
|
||||
name: 'Field Medic',
|
||||
cooldown_seconds: 12,
|
||||
runtime: selectedRuntime,
|
||||
tiers: [{ runtime: selectedRuntime }],
|
||||
},
|
||||
],
|
||||
chipSets: [
|
||||
{ id: 'chip-set-1', talent: { runtime: selectedRuntime } },
|
||||
],
|
||||
protocols: [
|
||||
{ id: 'protocol-1', talent: { runtime: selectedRuntime } },
|
||||
],
|
||||
specializations: [
|
||||
{
|
||||
id: 'specialization-1',
|
||||
focuses: [
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
ranks: [
|
||||
{ combatEffects: [{ runtime: selectedRuntime }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
mechanics,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,9 +234,82 @@ describe('shared Build access policy', () => {
|
||||
});
|
||||
expect(result?.build.visibility).toBe(visibility);
|
||||
expect(result?.build.owner).toBe(false);
|
||||
expect(result).not.toHaveProperty('mechanics');
|
||||
},
|
||||
);
|
||||
|
||||
it('serializes rendered Build fields without selected Mechanic Programs', async () => {
|
||||
mocks.row = buildRow('public');
|
||||
|
||||
const result = await loadSharedBuildProjection('field-medic-12345678', {
|
||||
kind: 'public-preview',
|
||||
});
|
||||
|
||||
expect(mocks.summarizeLoadout).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
mechanics,
|
||||
talents: [expect.objectContaining({ runtime: selectedRuntime })],
|
||||
}),
|
||||
);
|
||||
expect(result?.summary).toEqual({
|
||||
stats: [{ id: 'firepower', value: 42, sourceCount: 3 }],
|
||||
abilityStats: [
|
||||
{
|
||||
abilityId: 'ability-1',
|
||||
stats: [
|
||||
{
|
||||
id: 'skill-power',
|
||||
value: 18,
|
||||
sourceCount: 1,
|
||||
sources: [
|
||||
{
|
||||
source: 'Skill Chip 1 primary',
|
||||
chipIndex: 0,
|
||||
chipSetId: 'chip-set-1',
|
||||
value: 18,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
activeBonuses: [
|
||||
{
|
||||
name: 'Medic',
|
||||
pieces: 2,
|
||||
count: 3,
|
||||
statId: 'skill-power',
|
||||
value: 10,
|
||||
},
|
||||
],
|
||||
equippedTalents: [
|
||||
{
|
||||
id: 'talent-1',
|
||||
tier: 2,
|
||||
tierLabel: 'TIER II',
|
||||
source: 'Mask',
|
||||
description: 'Restore 15% Armor.',
|
||||
custom: { name: 'Medic Protocol' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result?.attributes).toEqual([
|
||||
{
|
||||
id: 'firepower',
|
||||
name: 'Firepower',
|
||||
unit: '%',
|
||||
category: 'Core',
|
||||
},
|
||||
]);
|
||||
expect(result?.talents).toEqual([
|
||||
{ id: 'talent-1', name: 'Field Medic', cooldown_seconds: 12 },
|
||||
]);
|
||||
expect(serializedMechanicInternals(JSON.parse(JSON.stringify(result)))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose private Builds to public previews or other viewers', async () => {
|
||||
mocks.row = buildRow('private');
|
||||
await expect(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { summarizeLoadout } from '$lib/builder/loadout-summary';
|
||||
import {
|
||||
summarizeLoadout,
|
||||
type LoadoutSummary,
|
||||
} from '$lib/builder/loadout-summary';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { loadSharedBuildSummaryCatalog } from '$lib/server/shared-build-summary';
|
||||
|
||||
@@ -8,6 +11,63 @@ export type SharedBuildAccessPolicy =
|
||||
{ kind: 'viewer-page'; viewerId: string | null } | { kind: 'public-preview' };
|
||||
type BuildVisibility = 'public' | 'unlisted' | 'private';
|
||||
|
||||
function publicFields<
|
||||
Value extends Record<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(
|
||||
slug: string,
|
||||
access: SharedBuildAccessPolicy,
|
||||
@@ -92,13 +152,30 @@ export async function loadSharedBuildProjection(
|
||||
updatedAt: String(build.updated_at),
|
||||
},
|
||||
loadout,
|
||||
gear,
|
||||
weapons,
|
||||
chips,
|
||||
protocols,
|
||||
brands,
|
||||
summary,
|
||||
attributes: summaryCatalog.attributes,
|
||||
talents: summaryCatalog.talents,
|
||||
gear: gear.map((item) =>
|
||||
publicFields(item, ['id', 'name', 'slot', 'tier', 'base_armor_min']),
|
||||
),
|
||||
weapons: weapons.map((item) =>
|
||||
publicFields(item, [
|
||||
'id',
|
||||
'name',
|
||||
'category',
|
||||
'rpm',
|
||||
'magazine',
|
||||
'reload_speed',
|
||||
'damage_type',
|
||||
'exotic_talent_id',
|
||||
]),
|
||||
),
|
||||
chips: chips.map((item) => publicFields(item, ['id', 'name'])),
|
||||
protocols: protocols.map((item) => publicFields(item, ['id', 'name'])),
|
||||
brands: brands.map((item) => publicFields(item, ['id', 'name'])),
|
||||
summary: publicLoadoutSummary(summary),
|
||||
attributes: summaryCatalog.attributes.map((item) =>
|
||||
publicFields(item, ['id', 'name', 'unit', 'category']),
|
||||
),
|
||||
talents: summaryCatalog.talents.map((item) =>
|
||||
publicFields(item, ['id', 'name', 'cooldown_seconds']),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { publicOrigin } from '$lib/server/public-origin';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
import { hydrateSkillChipProfileRows } from '$lib/skill-chips/profiles';
|
||||
import { loadBuildMechanicRuntimes } from '$lib/server/build-mechanic-runtime';
|
||||
|
||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||
const slugify = (value: string) =>
|
||||
@@ -284,6 +285,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationRankCosts,
|
||||
progression,
|
||||
rankProfiles,
|
||||
@@ -454,6 +456,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT attachment.*,effect.name AS definition_name
|
||||
FROM specialization_tree_rank_combat_effects attachment
|
||||
JOIN specialization_tree_node_ranks rank
|
||||
ON rank.node_id=attachment.node_id AND rank.rank=attachment.rank
|
||||
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
|
||||
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
|
||||
JOIN specializations specialization
|
||||
ON specialization.id=focus.specialization_id AND specialization.active=1
|
||||
JOIN specialization_combat_effects effect
|
||||
ON effect.id=attachment.combat_effect_id
|
||||
AND effect.specialization_id=focus.specialization_id
|
||||
AND effect.active=1
|
||||
ORDER BY specialization.id,focus.display_order,focus.name COLLATE NOCASE,
|
||||
node.row_number,node.column_number,attachment.rank,
|
||||
attachment.effect_order,attachment.id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
||||
@@ -470,6 +489,14 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const mechanics = await loadBuildMechanicRuntimes(database, {
|
||||
talentIds: talents.map((talent) => String(talent.id)),
|
||||
protocolTalentIds: protocolTalents.map((talent) => String(talent.id)),
|
||||
skillChipTalentIds: chipTalents.map((talent) => String(talent.id)),
|
||||
specializationAttachmentIds: specializationRankCombatEffects.map((effect) =>
|
||||
String(effect.id),
|
||||
),
|
||||
});
|
||||
const iconUrl = (
|
||||
type:
|
||||
| 'talent'
|
||||
@@ -488,6 +515,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
runtime: mechanics.talents[String(talent.id)] ?? null,
|
||||
iconUrl: iconUrl('talent', talent.id),
|
||||
template:
|
||||
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
|
||||
@@ -505,6 +533,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||
publicOrigin: publicOrigin(url.origin),
|
||||
initialBuild,
|
||||
mechanics,
|
||||
attributes,
|
||||
coreRanges,
|
||||
gear: gear.map((item) => ({
|
||||
@@ -584,6 +613,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
runtime: mechanics.skillChipTalents[String(talent.id)] ?? null,
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
@@ -614,6 +644,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
runtime: mechanics.protocolTalents[String(talent.id)] ?? null,
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
@@ -664,6 +695,23 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: {
|
||||
id: String(effect.combat_effect_id),
|
||||
name: String(effect.definition_name),
|
||||
},
|
||||
runtime:
|
||||
mechanics.specializationAttachments[
|
||||
String(effect.id)
|
||||
] ?? null,
|
||||
})),
|
||||
costs: specializationRankCosts.filter(
|
||||
(cost) =>
|
||||
cost.node_id === node.id &&
|
||||
|
||||
@@ -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 { getRegistrySearch } from '$lib/server/pagination';
|
||||
import { databaseCategories } from '$lib/database/categories';
|
||||
import {
|
||||
loadMechanicPublicProjectionsForOwners,
|
||||
type MechanicPublicProjectionOwnerRef,
|
||||
type MechanicPublicProjectionsByOwner,
|
||||
} from '$lib/server/mechanic-public-projection';
|
||||
import {
|
||||
databaseMechanicGroup,
|
||||
databaseMechanicOwnersMatchSearch,
|
||||
databaseMechanicSearchSummary,
|
||||
} from './public-mechanics';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const tables = {
|
||||
@@ -28,45 +38,90 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
return { ...category, count: Number(row?.count ?? 0) };
|
||||
}),
|
||||
);
|
||||
const searchResults = search
|
||||
const rawSearchResults = search
|
||||
? await (
|
||||
await database.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT 'gear' AS slug,'Gear' AS category,item.name,
|
||||
SELECT 'gear' AS slug,'Gear' AS category,item.id AS parent_id,item.name,
|
||||
item.slot || ' · T' || item.tier AS detail
|
||||
FROM gear item
|
||||
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
||||
WHERE item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.slot LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM gear_talents link
|
||||
JOIN talents linked_talent
|
||||
ON linked_talent.id=link.talent_id AND linked_talent.active=1
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE link.gear_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'weapons','Weapons',item.name,
|
||||
SELECT 'weapons','Weapons',item.id,item.name,
|
||||
item.category || ' · ' || item.damage_type || ' damage'
|
||||
FROM weapons item
|
||||
WHERE item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.damage_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM talents linked_talent
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE linked_talent.active=1
|
||||
AND (
|
||||
linked_talent.id=item.exotic_talent_id
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM weapon_talent_options link
|
||||
WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id
|
||||
)
|
||||
)
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'weapon-mods','Weapon Mods',item.name,item.slot_type
|
||||
SELECT 'weapon-mods','Weapon Mods',item.id,item.name,item.slot_type
|
||||
FROM weapon_mods item
|
||||
WHERE item.active=1 AND (item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.slot_type LIKE ? ESCAPE '\\' COLLATE NOCASE)
|
||||
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'
|
||||
SELECT 'gear-mods','Gear Mods',item.id,item.name,'Gear modification'
|
||||
FROM gear_mods item
|
||||
WHERE item.active=1 AND item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
UNION ALL
|
||||
SELECT 'os-protocols','OS Protocols',item.name,
|
||||
SELECT 'os-protocols','OS Protocols',item.id,item.name,
|
||||
item.quality || ' · ' || item.primary_attribute_type
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_talents talent ON talent.protocol_id=item.id
|
||||
WHERE item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.quality LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.primary_attribute_type LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM os_protocol_talents talent
|
||||
WHERE talent.protocol_id=item.id AND (
|
||||
talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR talent.description_template LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mechanic_authored_sources source
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE source.owner_type='os_protocol_talent' AND source.owner_id=talent.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'talents','Talents',item.name,item.category
|
||||
SELECT 'talents','Talents',item.id,item.name,item.category
|
||||
FROM talents item
|
||||
WHERE item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR item.category LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM talent_tiers tier
|
||||
WHERE tier.talent_id=item.id AND (
|
||||
@@ -75,9 +130,15 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
OR tier.effect_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mechanic_authored_sources source
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE source.owner_type='talent' AND source.owner_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'brands','Brands',item.name,
|
||||
SELECT 'brands','Brands',item.id,item.name,
|
||||
COALESCE((
|
||||
SELECT GROUP_CONCAT(attribute.name, ', ')
|
||||
FROM brand_set_effects effect
|
||||
@@ -90,22 +151,37 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
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
|
||||
WHERE effect.brand_set_id=item.id
|
||||
AND attribute.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'skill-chips','Skill Chips',item.name,COALESCE(talent.name,'Skill Chip set')
|
||||
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
|
||||
LEFT JOIN skill_chip_talents talent ON talent.skill_chip_set_id=item.id
|
||||
WHERE item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE OR talent.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
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.name,'Specialization'
|
||||
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
|
||||
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
|
||||
@@ -116,22 +192,311 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
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
|
||||
LIMIT 40
|
||||
`)
|
||||
).all(...Array(27).fill(pattern))
|
||||
).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 {
|
||||
registries: counts,
|
||||
total: counts.reduce((total, registry) => total + registry.count, 0),
|
||||
search,
|
||||
searchResults: searchResults.map((result) => ({
|
||||
searchResults: searchResults.map((result) => {
|
||||
const groups = (
|
||||
ownersByResult.get(
|
||||
resultKey(String(result.slug), String(result.parent_id)),
|
||||
) ?? []
|
||||
)
|
||||
.map((owner) =>
|
||||
databaseMechanicGroup(owner, publicMechanics, { label: 'BEHAVIOR' }),
|
||||
)
|
||||
.filter((group) => group !== null);
|
||||
const group =
|
||||
groups.find((candidate) =>
|
||||
candidate.variants.some((variant) =>
|
||||
variant.behaviorText.toLocaleLowerCase().includes(normalizedSearch),
|
||||
),
|
||||
) ??
|
||||
groups[0] ??
|
||||
null;
|
||||
const behavior = databaseMechanicSearchSummary(group);
|
||||
return {
|
||||
slug: String(result.slug),
|
||||
category: String(result.category),
|
||||
name: String(result.name),
|
||||
detail: String(result.detail ?? ''),
|
||||
})),
|
||||
...(behavior ?? {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
<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();
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -31,11 +46,13 @@
|
||||
{#if data.searchResults.length}
|
||||
<div>
|
||||
{#each data.searchResults as result}
|
||||
{@const resultStatus = status(result)}
|
||||
<a href={`/database/${result.slug}?q=${encodeURIComponent(result.name)}`}>
|
||||
<small>{result.category}</small>
|
||||
<strong>{result.name}</strong>
|
||||
<span>{result.detail}</span>
|
||||
<b>VIEW →</b>
|
||||
<strong class="database-global-result-identity">{result.name}</strong>
|
||||
{#if resultStatus}<DatabaseMechanicStatusBadge status={resultStatus}/>{/if}
|
||||
<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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { databaseCategory } from '$lib/database/categories';
|
||||
import type { DatabaseFact, DatabaseItem } from '$lib/database/items';
|
||||
import type { DatabaseItem, DatabaseMechanicGroup } from '$lib/database/items';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import { gearBaseStatRange, weaponBaseRange } from '$lib/builder/loadout';
|
||||
import {
|
||||
@@ -44,6 +44,15 @@ import {
|
||||
publicWeaponModSlots,
|
||||
weaponModSlotDefinitionByName,
|
||||
} from '$lib/weapons/mod-slots';
|
||||
import {
|
||||
loadMechanicPublicProjectionsForOwners,
|
||||
type MechanicPublicProjectionOwnerRef,
|
||||
type MechanicPublicProjectionsByOwner,
|
||||
} from '$lib/server/mechanic-public-projection';
|
||||
import {
|
||||
databaseMechanicGroup,
|
||||
databaseMechanicOwnersMatchSearch,
|
||||
} from '../public-mechanics';
|
||||
|
||||
const number = (value: unknown) => {
|
||||
const numeric = Number(value);
|
||||
@@ -65,19 +74,9 @@ const weaponIcon = (category: unknown) =>
|
||||
String(category).toLowerCase() as GameIconName;
|
||||
const iconUrl = (type: GameDataIconType, id: unknown, updatedAt: unknown) =>
|
||||
updatedAt ? gameDataIconUrl(type, String(id), updatedAt) : '';
|
||||
const parsedObject = (value: unknown): Record<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 = (
|
||||
row: Record<string, any>,
|
||||
mechanics: Array<Record<string, any>>,
|
||||
row: Record<string, unknown>,
|
||||
mechanicGroups: DatabaseMechanicGroup[],
|
||||
weaponSlotOverride?: number,
|
||||
): DatabaseItem => {
|
||||
const talentCategory = String(row.category);
|
||||
@@ -91,27 +90,6 @@ const talentCardItem = (
|
||||
isWeaponTalent && weaponSlot
|
||||
? `WEAPON ${weaponSlot}`
|
||||
: normalizedCategory.toUpperCase();
|
||||
const afterLines: DatabaseFact[] = [
|
||||
...(row.duration_seconds
|
||||
? [
|
||||
{
|
||||
label: 'EFFECT DURATION',
|
||||
value: `${number(row.duration_seconds)} SECONDS`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(Number(row.max_stacks) > 1
|
||||
? [{ label: 'MAXIMUM STACKS', value: String(row.max_stacks) }]
|
||||
: []),
|
||||
...(row.cooldown_seconds
|
||||
? [
|
||||
{
|
||||
label: 'TALENT COOLDOWN',
|
||||
value: `${number(row.cooldown_seconds)} SECONDS`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const filterKey = isWeaponTalent ? 'weaponTalent' : 'talent';
|
||||
const destination = isWeaponTalent ? 'weapons' : 'gear';
|
||||
|
||||
@@ -136,14 +114,7 @@ const talentCardItem = (
|
||||
: []),
|
||||
],
|
||||
facts: [],
|
||||
description: row.tier_i
|
||||
? String(row.tier_i)
|
||||
: 'Description is not available.',
|
||||
secondaryDescription: row.tier_ii
|
||||
? String(row.tier_ii)
|
||||
: 'Description is not available.',
|
||||
descriptionStyle: 'data',
|
||||
afterLines,
|
||||
mechanicGroups,
|
||||
actions: [
|
||||
{
|
||||
label: `FIND COMPATIBLE ${isWeaponTalent ? 'WEAPONS' : 'GEAR'}`,
|
||||
@@ -179,6 +150,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
}> = [];
|
||||
let countSql = '';
|
||||
let rowsSql = '';
|
||||
let behaviorSearchCandidateSql: string | null = null;
|
||||
|
||||
switch (category.slug) {
|
||||
case 'gear': {
|
||||
@@ -195,7 +167,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT talents.id,talents.name,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||||
icons.updated_at FROM talents
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE talents.active=1 AND talents.category IN ('Backpack','Body Armor','Vest')
|
||||
@@ -224,8 +195,19 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
conditions.push(`(
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM gear_talents linked_talent
|
||||
JOIN talents ON talents.id=linked_talent.talent_id AND talents.active=1
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='talent' AND source.owner_id=talents.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE linked_talent.gear_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`);
|
||||
queryArgs.push(pattern, pattern);
|
||||
}
|
||||
if (selectedSlots.length) {
|
||||
conditions.push(
|
||||
@@ -244,6 +226,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
queryArgs.push(...selectedTalents);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM gear item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM gear item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM gear item
|
||||
@@ -282,7 +267,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
options: talentRows.map((row) => ({
|
||||
value: String(row.id),
|
||||
label: String(row.name),
|
||||
description: String(row.tier_i_description ?? ''),
|
||||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
||||
})),
|
||||
},
|
||||
@@ -291,13 +275,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
}
|
||||
case 'weapons': {
|
||||
const talentRows = await (
|
||||
await database.prepare(`SELECT DISTINCT talents.id,talents.name,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i_description,
|
||||
icons.updated_at
|
||||
FROM weapon_talent_options options
|
||||
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id AND source_weapons.active=1
|
||||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||||
await database.prepare(`SELECT talents.id,talents.name,icons.updated_at
|
||||
FROM talents
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE talents.active=1 AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM weapon_talent_options options
|
||||
JOIN weapons source_weapons ON source_weapons.id=options.weapon_id
|
||||
WHERE source_weapons.active=1 AND options.talent_id=talents.id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM weapons source_weapons
|
||||
WHERE source_weapons.active=1 AND source_weapons.exotic_talent_id=talents.id
|
||||
)
|
||||
)
|
||||
ORDER BY talents.name COLLATE NOCASE`)
|
||||
).all();
|
||||
const weaponTypes = ['Shotgun', 'Rifle', 'MMR', 'AR', 'LMG', 'SMG'];
|
||||
@@ -315,8 +306,25 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
conditions.push(`(
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM talents linked_talent
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='talent' AND source.owner_id=linked_talent.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE linked_talent.active=1
|
||||
AND (
|
||||
linked_talent.id=item.exotic_talent_id
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM weapon_talent_options link
|
||||
WHERE link.weapon_id=item.id AND link.talent_id=linked_talent.id
|
||||
)
|
||||
)
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`);
|
||||
queryArgs.push(pattern, pattern);
|
||||
}
|
||||
if (selectedTypes.length) {
|
||||
conditions.push(
|
||||
@@ -331,11 +339,20 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
queryArgs.push(...selectedDamageTypes);
|
||||
}
|
||||
if (selectedTalents.length) {
|
||||
conditions.push(`EXISTS (SELECT 1 FROM weapon_talent_options filter_talents
|
||||
WHERE filter_talents.weapon_id=item.id AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')}))`);
|
||||
queryArgs.push(...selectedTalents);
|
||||
conditions.push(`(
|
||||
EXISTS (
|
||||
SELECT 1 FROM weapon_talent_options filter_talents
|
||||
WHERE filter_talents.weapon_id=item.id
|
||||
AND filter_talents.talent_id IN (${selectedTalents.map(() => '?').join(',')})
|
||||
)
|
||||
OR item.exotic_talent_id IN (${selectedTalents.map(() => '?').join(',')})
|
||||
)`);
|
||||
queryArgs.push(...selectedTalents, ...selectedTalents);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM weapons item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM weapons item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,render.id AS render_id,render.updated_at AS render_updated_at,
|
||||
native.damage_min,native.damage_max,native.reference_level,
|
||||
@@ -377,7 +394,6 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
options: talentRows.map((row) => ({
|
||||
value: String(row.id),
|
||||
label: String(row.name),
|
||||
description: String(row.tier_i_description ?? ''),
|
||||
iconUrl: iconUrl('talent', row.id, row.updated_at),
|
||||
})),
|
||||
},
|
||||
@@ -409,10 +425,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'os-protocols':
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE item.active=1${searchClause}`;
|
||||
case 'os-protocols': {
|
||||
const where = search
|
||||
? `item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM os_protocol_talents talent
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='os_protocol_talent' AND source.owner_id=talent.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE talent.protocol_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`
|
||||
: 'item.active=1';
|
||||
queryArgs = search ? [pattern, pattern] : [];
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM os_protocols item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM os_protocols item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,attributes.name AS primary_stat,attributes.unit AS primary_unit,
|
||||
primary_effect.base_value,talents.name AS talent_name,talents.description_template AS talent_description,icons.updated_at AS icon_updated_at,
|
||||
primary_effect.base_value,talents.id AS talent_id,talents.name AS talent_name,icons.updated_at AS icon_updated_at,
|
||||
render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM os_protocols item
|
||||
LEFT JOIN os_protocol_primary_effects primary_effect ON primary_effect.protocol_id=item.id
|
||||
@@ -420,9 +453,10 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
LEFT JOIN os_protocol_talents talents ON talents.protocol_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='os_protocol' AND icons.entity_id=item.id
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='os_protocol_render' AND render.source_entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
WHERE ${where}
|
||||
ORDER BY item.quality,item.primary_attribute_type,item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
}
|
||||
case 'talents': {
|
||||
const talentSlots = [
|
||||
{ value: 'Backpack', label: 'Backpack', gameIcon: 'backpack' as const },
|
||||
@@ -448,8 +482,16 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
const conditions = ['item.active=1'];
|
||||
queryArgs = [];
|
||||
if (search) {
|
||||
conditions.push("item.name LIKE ? ESCAPE '\\' COLLATE NOCASE");
|
||||
queryArgs.push(pattern);
|
||||
conditions.push(`(
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM mechanic_authored_sources source
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE source.owner_type='talent' AND source.owner_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`);
|
||||
queryArgs.push(pattern, pattern);
|
||||
}
|
||||
if (selectedTalentSlots.length) {
|
||||
const slotConditions = selectedTalentSlots.map((slot) => {
|
||||
@@ -467,10 +509,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
);
|
||||
}
|
||||
const where = conditions.join(' AND ');
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM talents item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM talents item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=item.id AND tiers.tier='II') AS tier_ii,
|
||||
(SELECT slot FROM weapon_talent_slots slots WHERE slots.talent_id=item.id) AS weapon_slot,
|
||||
(SELECT scope FROM talent_weapon_scopes scopes WHERE scopes.talent_id=item.id) AS weapon_scope,
|
||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
@@ -499,10 +542,27 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
WHERE item.active=1${searchClause}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'skill-chips':
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE item.active=1${searchClause}`;
|
||||
case 'skill-chips': {
|
||||
const where = search
|
||||
? `item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM skill_chip_talents talent
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='skill_chip_talent' AND source.owner_id=talent.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE talent.skill_chip_set_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`
|
||||
: 'item.active=1';
|
||||
queryArgs = search ? [pattern, pattern] : [];
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM skill_chip_sets item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM skill_chip_sets item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,bonus.min_value,bonus.max_value,attributes.name AS bonus_stat,attributes.unit AS bonus_unit,
|
||||
talents.name AS talent_name,talents.description_template AS talent_description,
|
||||
talents.id AS talent_id,talents.name AS talent_name,
|
||||
icons.updated_at AS icon_updated_at,render.id AS render_id,render.updated_at AS render_updated_at
|
||||
FROM skill_chip_sets item
|
||||
LEFT JOIN skill_chip_two_piece_bonuses bonus ON bonus.skill_chip_set_id=item.id
|
||||
@@ -510,11 +570,35 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
LEFT JOIN skill_chip_talents talents ON talents.skill_chip_set_id=item.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='skill_chip' AND icons.entity_id=item.id
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='skill_chip_render' AND render.source_entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
WHERE ${where}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
case 'specializations':
|
||||
countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE item.active=1${searchClause}`;
|
||||
}
|
||||
case 'specializations': {
|
||||
const where = search
|
||||
? `item.active=1 AND (
|
||||
item.name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM specialization_tree_rank_combat_effects attachment
|
||||
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
|
||||
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
|
||||
JOIN specialization_combat_effects combat_effect
|
||||
ON combat_effect.id=attachment.combat_effect_id
|
||||
AND combat_effect.specialization_id=focus.specialization_id
|
||||
AND combat_effect.active=1
|
||||
JOIN mechanic_authored_sources source
|
||||
ON source.owner_type='specialization_tree_rank' AND source.owner_id=attachment.id
|
||||
JOIN mechanic_public_variants variant ON variant.source_id=source.id
|
||||
WHERE focus.specialization_id=item.id
|
||||
AND variant.behavior_text LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
)
|
||||
)`
|
||||
: 'item.active=1';
|
||||
queryArgs = search ? [pattern, pattern] : [];
|
||||
behaviorSearchCandidateSql = search
|
||||
? `SELECT item.id,item.name FROM specializations item WHERE ${where}`
|
||||
: null;
|
||||
countSql = `SELECT COUNT(*) AS total FROM specializations item WHERE ${where}`;
|
||||
rowsSql = `SELECT item.*,
|
||||
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.active=1) AS focus_count,
|
||||
(SELECT COUNT(*) FROM specialization_focuses focus WHERE focus.specialization_id=item.id AND focus.builder_ready=1 AND focus.active=1) AS ready_focus_count,
|
||||
@@ -536,41 +620,180 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
FROM specializations item
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='specialization' AND icons.entity_id=item.id
|
||||
LEFT JOIN managed_images render ON render.source_entity_type='specialization_render' AND render.source_entity_id=item.id
|
||||
WHERE item.active=1${searchClause}
|
||||
WHERE ${where}
|
||||
ORDER BY item.name COLLATE NOCASE LIMIT ? OFFSET ?`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const total = Number(
|
||||
// Cache text may identify candidates, but only canonical projections decide
|
||||
// whether behavior matches and therefore contributes to totals or pages.
|
||||
let validatedBehaviorSearchIds: string[] | null = null;
|
||||
if (behaviorSearchCandidateSql) {
|
||||
const candidates = await (
|
||||
await database.prepare(behaviorSearchCandidateSql)
|
||||
).all(...queryArgs);
|
||||
const candidateIds = candidates.map((candidate) => String(candidate.id));
|
||||
const ownersByCandidate = new Map<
|
||||
string,
|
||||
MechanicPublicProjectionOwnerRef[]
|
||||
>();
|
||||
const addOwner = (
|
||||
parentId: unknown,
|
||||
owner: MechanicPublicProjectionOwnerRef,
|
||||
) => {
|
||||
const id = String(parentId);
|
||||
ownersByCandidate.set(id, [...(ownersByCandidate.get(id) ?? []), owner]);
|
||||
};
|
||||
if (candidateIds.length) {
|
||||
let ownerRows: Array<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 rows = await (
|
||||
await database.prepare(rowsSql)
|
||||
).all(...queryArgs, pagination.perPage, pagination.offset);
|
||||
const protocolTalentEffects =
|
||||
category.slug === 'os-protocols' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT talents.protocol_id,effects.base_value,attributes.unit
|
||||
FROM os_protocol_talents talents
|
||||
JOIN os_protocol_talent_effects effects ON effects.talent_id=talents.id
|
||||
JOIN stat_attributes attributes ON attributes.id=effects.stat_attribute_id
|
||||
WHERE talents.protocol_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY talents.protocol_id,effects.sort_order`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const rows =
|
||||
validatedBehaviorSearchIds !== null && !validatedBehaviorSearchIds.length
|
||||
? []
|
||||
: await (
|
||||
await database.prepare(
|
||||
validatedBehaviorSearchIds
|
||||
? `${rowsSql.slice(0, rowsSql.lastIndexOf('ORDER BY'))}AND item.id IN (${validatedBehaviorSearchIds
|
||||
.map(() => '?')
|
||||
.join(
|
||||
',',
|
||||
)}) ${rowsSql.slice(rowsSql.lastIndexOf('ORDER BY'))}`
|
||||
: rowsSql,
|
||||
)
|
||||
).all(
|
||||
...queryArgs,
|
||||
...(validatedBehaviorSearchIds ?? []),
|
||||
pagination.perPage,
|
||||
pagination.offset,
|
||||
);
|
||||
const weaponRelations =
|
||||
category.slug === 'weapons' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT options.weapon_id,options.talent_slot,talents.*,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii,
|
||||
await database.prepare(`SELECT relation.weapon_id,relation.talent_slot,relation.kind AS relation_kind,
|
||||
talents.*,COALESCE(scopes.scope,CASE relation.kind WHEN 'exotic' THEN 'Exotic' END) AS weapon_scope,
|
||||
icons.updated_at AS icon_updated_at
|
||||
FROM weapon_talent_options options
|
||||
JOIN talents ON talents.id=options.talent_id AND talents.active=1
|
||||
FROM (
|
||||
SELECT weapon_id,talent_slot,talent_id,'standard' AS kind FROM weapon_talent_options
|
||||
UNION ALL
|
||||
SELECT id AS weapon_id,NULL AS talent_slot,exotic_talent_id AS talent_id,'exotic' AS kind
|
||||
FROM weapons WHERE exotic_talent_id IS NOT NULL
|
||||
) relation
|
||||
JOIN talents ON talents.id=relation.talent_id AND talents.active=1
|
||||
LEFT JOIN talent_weapon_scopes scopes ON scopes.talent_id=talents.id
|
||||
LEFT JOIN game_data_icons icons ON icons.entity_type='talent' AND icons.entity_id=talents.id
|
||||
WHERE options.weapon_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY options.talent_slot,talents.name COLLATE NOCASE`)
|
||||
WHERE relation.weapon_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY relation.weapon_id,relation.kind,relation.talent_slot,talents.name COLLATE NOCASE`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const weaponModSlotRows =
|
||||
@@ -594,16 +817,11 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
? await (
|
||||
await database.prepare(`SELECT relation.*,icons.updated_at
|
||||
FROM (
|
||||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,
|
||||
NULL AS category,NULL AS cooldown_seconds,NULL AS duration_seconds,NULL AS max_stacks,
|
||||
NULL AS tier_i,NULL AS tier_ii
|
||||
SELECT links.gear_id,'brand' AS kind,brands.id,brands.name,NULL AS category
|
||||
FROM gear_brand_sets links JOIN brand_sets brands ON brands.id=links.brand_set_id
|
||||
WHERE brands.active=1
|
||||
UNION ALL
|
||||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,
|
||||
talents.category,talents.cooldown_seconds,talents.duration_seconds,talents.max_stacks,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='I') AS tier_i,
|
||||
(SELECT description FROM talent_tiers tiers WHERE tiers.talent_id=talents.id AND tiers.tier='II') AS tier_ii
|
||||
SELECT links.gear_id,'talent' AS kind,talents.id,talents.name,talents.category
|
||||
FROM gear_talents links JOIN talents ON talents.id=links.talent_id
|
||||
WHERE talents.active=1
|
||||
) relation
|
||||
@@ -640,40 +858,113 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
WHERE effects.brand_set_id IN (${rows.map(() => '?').join(',')}) ORDER BY effects.pieces_required`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const talentIds = [
|
||||
...new Set(
|
||||
category.slug === 'talents'
|
||||
? rows.map((row) => String(row.id))
|
||||
: category.slug === 'weapons'
|
||||
? weaponRelations.map((relation) => String(relation.id))
|
||||
: category.slug === 'gear'
|
||||
? gearRelations
|
||||
.filter((relation) => relation.kind === 'talent')
|
||||
.map((relation) => String(relation.id))
|
||||
: [],
|
||||
),
|
||||
];
|
||||
const talentMechanics = talentIds.length
|
||||
const specializationMechanicRows =
|
||||
category.slug === 'specializations' && rows.length
|
||||
? await (
|
||||
await database.prepare(`SELECT rules.source_id AS talent_id,rules.id AS rule_id,
|
||||
clauses.id AS clause_id,clauses.label AS clause_label,clauses.event_kind,
|
||||
clauses.conditions_json,clauses.reducer_json,clauses.cooldown_seconds,
|
||||
outcomes.variant_key,outcomes.outcome_key,outcomes.application_kind,outcomes.effect_scope,
|
||||
outcomes.value,outcomes.damage_basis,attributes.name AS attribute_name,attributes.unit AS attribute_unit,
|
||||
outcomes.sort_order
|
||||
FROM conditional_effect_rules rules
|
||||
JOIN conditional_effect_clauses clauses ON clauses.rule_id=rules.id AND clauses.active=1
|
||||
LEFT JOIN conditional_effect_outcomes outcomes ON outcomes.clause_id=clauses.id
|
||||
LEFT JOIN stat_attributes attributes ON attributes.id=outcomes.stat_attribute_id
|
||||
WHERE rules.source_type='talent' AND rules.configured=1
|
||||
AND rules.source_id IN (${talentIds.map(() => '?').join(',')})
|
||||
ORDER BY rules.source_id,clauses.sort_order,outcomes.outcome_key,outcomes.sort_order`)
|
||||
).all(...talentIds)
|
||||
await database.prepare(`SELECT attachment.id AS attachment_id,
|
||||
focus.specialization_id,focus.name AS focus_name,focus.display_order AS focus_display_order,
|
||||
node.name AS node_name,node.row_number,node.column_number,
|
||||
attachment.rank,attachment.effect_order,combat_effect.name AS combat_effect_name
|
||||
FROM specialization_tree_rank_combat_effects attachment
|
||||
JOIN specialization_tree_nodes node ON node.id=attachment.node_id AND node.active=1
|
||||
JOIN specialization_focuses focus ON focus.id=node.focus_id AND focus.active=1
|
||||
JOIN specialization_combat_effects combat_effect
|
||||
ON combat_effect.id=attachment.combat_effect_id
|
||||
AND combat_effect.specialization_id=focus.specialization_id
|
||||
AND combat_effect.active=1
|
||||
WHERE focus.specialization_id IN (${rows.map(() => '?').join(',')})
|
||||
ORDER BY focus.display_order,focus.name COLLATE NOCASE,node.row_number,node.column_number,
|
||||
attachment.rank,attachment.effect_order`)
|
||||
).all(...rows.map((row) => row.id))
|
||||
: [];
|
||||
const mechanicsFor = (talentId: unknown) =>
|
||||
talentMechanics.filter(
|
||||
(mechanic) => String(mechanic.talent_id) === String(talentId),
|
||||
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 uniqueMechanicOwners = [
|
||||
...new Map(
|
||||
mechanicOwners.map((owner) => [`${owner.type}\u0000${owner.id}`, owner]),
|
||||
).values(),
|
||||
] as MechanicPublicProjectionOwnerRef[];
|
||||
const publicMechanics: MechanicPublicProjectionsByOwner =
|
||||
uniqueMechanicOwners.length
|
||||
? await loadMechanicPublicProjectionsForOwners(
|
||||
database,
|
||||
uniqueMechanicOwners,
|
||||
)
|
||||
: ({} as MechanicPublicProjectionsByOwner);
|
||||
const talentMechanicGroups = (id: unknown) => {
|
||||
const group = databaseMechanicGroup(
|
||||
{ type: 'talent', id: String(id) },
|
||||
publicMechanics,
|
||||
{ label: 'TALENT' },
|
||||
);
|
||||
return group ? [group] : [];
|
||||
};
|
||||
const protocolMechanicGroups = (id: unknown) => {
|
||||
if (!id) return [];
|
||||
const group = databaseMechanicGroup(
|
||||
{ type: 'os_protocol_talent', id: String(id) },
|
||||
publicMechanics,
|
||||
{ label: 'PROTOCOL TALENT' },
|
||||
);
|
||||
return group ? [group] : [];
|
||||
};
|
||||
const skillChipMechanicGroups = (id: unknown) => {
|
||||
if (!id) return [];
|
||||
const group = databaseMechanicGroup(
|
||||
{ type: 'skill_chip_talent', id: String(id) },
|
||||
publicMechanics,
|
||||
{ label: 'SKILL CHIP TALENT' },
|
||||
);
|
||||
return group ? [group] : [];
|
||||
};
|
||||
const specializationMechanicGroups = (specializationId: unknown) =>
|
||||
specializationMechanicRows.flatMap((row) => {
|
||||
if (String(row.specialization_id) !== String(specializationId)) return [];
|
||||
const group = databaseMechanicGroup(
|
||||
{ type: 'specialization_tree_rank', id: String(row.attachment_id) },
|
||||
publicMechanics,
|
||||
{
|
||||
id: String(row.attachment_id),
|
||||
label: String(row.combat_effect_name),
|
||||
context: `${String(row.focus_name)} // ${String(row.node_name)} // RANK ${String(row.rank)} // ROW ${String(row.row_number)}, COLUMN ${String(row.column_number)} // EFFECT ${String(row.effect_order)}`,
|
||||
},
|
||||
);
|
||||
return group ? [group] : [];
|
||||
});
|
||||
|
||||
const items: DatabaseItem[] = rows.map((row) => {
|
||||
switch (category.slug) {
|
||||
@@ -737,7 +1028,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
...relation,
|
||||
icon_updated_at: relation.updated_at,
|
||||
},
|
||||
mechanicsFor(relation.id),
|
||||
talentMechanicGroups(relation.id),
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -825,9 +1116,13 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
: '',
|
||||
};
|
||||
});
|
||||
const talentItems = (slot: number) =>
|
||||
const talentItems = (slot: number | 'exotic') =>
|
||||
relatedTalents
|
||||
.filter((relation) => Number(relation.talent_slot) === slot)
|
||||
.filter((relation) =>
|
||||
slot === 'exotic'
|
||||
? relation.relation_kind === 'exotic'
|
||||
: Number(relation.talent_slot) === slot,
|
||||
)
|
||||
.map((relation) => {
|
||||
const talentIcon = iconUrl(
|
||||
'talent',
|
||||
@@ -840,8 +1135,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
iconUrl: talentIcon,
|
||||
preview: talentCardItem(
|
||||
relation,
|
||||
mechanicsFor(relation.id),
|
||||
slot,
|
||||
talentMechanicGroups(relation.id),
|
||||
typeof slot === 'number' ? slot : undefined,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -899,6 +1194,15 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
items: talentItems(2),
|
||||
emptyLabel: 'NO TALENTS ASSIGNED',
|
||||
},
|
||||
...(Number(row.is_exotic)
|
||||
? [
|
||||
{
|
||||
label: 'EXOTIC TALENT',
|
||||
items: talentItems('exotic'),
|
||||
emptyLabel: 'NO EXOTIC TALENT ASSIGNED',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -961,7 +1265,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'os-protocols':
|
||||
case 'os-protocols': {
|
||||
const mechanicGroups = protocolMechanicGroups(row.talent_id);
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
@@ -979,22 +1284,14 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
primaryStat: String(row.primary_stat ?? 'NOT CONFIGURED'),
|
||||
primaryUnit: String(row.primary_unit ?? ''),
|
||||
primaryBaseValue: Number(row.base_value),
|
||||
talentDescription: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
talentEffects: protocolTalentEffects
|
||||
.filter((effect) => effect.protocol_id === row.id)
|
||||
.map((effect) => ({
|
||||
baseValue: Number(effect.base_value),
|
||||
unit: String(effect.unit ?? ''),
|
||||
})),
|
||||
talentDescription: mechanicGroups[0]?.variants[0]?.behaviorText,
|
||||
talentEffects: [],
|
||||
},
|
||||
description: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
mechanicGroups,
|
||||
};
|
||||
}
|
||||
case 'talents':
|
||||
return talentCardItem(row, mechanicsFor(row.id));
|
||||
return talentCardItem(row, talentMechanicGroups(row.id));
|
||||
case 'brands': {
|
||||
const effects = brandEffects.filter(
|
||||
(effect) => effect.brand_set_id === row.id,
|
||||
@@ -1020,7 +1317,8 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
})),
|
||||
};
|
||||
}
|
||||
case 'skill-chips':
|
||||
case 'skill-chips': {
|
||||
const mechanicGroups = skillChipMechanicGroups(row.talent_id);
|
||||
return {
|
||||
id: String(row.id),
|
||||
name: String(row.name),
|
||||
@@ -1044,10 +1342,9 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
value: list(row.talent_name, 'NOT CONFIGURED'),
|
||||
},
|
||||
],
|
||||
description: row.talent_description
|
||||
? String(row.talent_description)
|
||||
: undefined,
|
||||
mechanicGroups,
|
||||
};
|
||||
}
|
||||
case 'specializations':
|
||||
return {
|
||||
id: String(row.id),
|
||||
@@ -1070,6 +1367,7 @@ export const load: PageServerLoad = async ({ params, url, cookies }) => {
|
||||
{ label: 'ABILITIES', value: list(row.ability_names) },
|
||||
],
|
||||
description: row.description ? String(row.description) : undefined,
|
||||
mechanicGroups: specializationMechanicGroups(row.id),
|
||||
};
|
||||
}
|
||||
}) as DatabaseItem[];
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
<DatabaseCategoryNav active={data.category.slug}/>
|
||||
<DatabaseDiscoveryConsole filters={data.filters} pagination={data.pagination} search={data.search} searchPlaceholder={`SEARCH ${data.category.label.toUpperCase()} BY NAME`}/>
|
||||
<section class="database-item-grid">
|
||||
{#each data.items as item}
|
||||
<DatabaseItemCard item={item as any}/>
|
||||
{#each data.items as item (item.id)}
|
||||
<DatabaseItemCard {item}/>
|
||||
{:else}
|
||||
<div class="database-empty">
|
||||
<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