feat: consume selected mechanic revisions

This commit is contained in:
jb
2026-09-02 23:32:42 -04:00
parent 7e392a1f37
commit 8991682e59
31 changed files with 6921 additions and 469 deletions
+28
View File
@@ -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
View File
@@ -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;
}
}
+570 -60
View File
@@ -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);
}
});
});
+248 -88
View File
@@ -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(
+83 -17
View File
@@ -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';
+20 -8
View File
@@ -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(
+37 -5
View File
@@ -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}
+4 -3
View File
@@ -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>
+60
View File
@@ -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');
});
});
+48
View File
@@ -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();
});
});
+114
View File
@@ -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,
];
}),
);
}
+306
View File
@@ -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,
]);
});
});
+48
View File
@@ -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,
})),
})),
})),
})),
+253 -5
View File
@@ -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(
+86 -9
View File
@@ -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']),
),
};
}
+48
View File
@@ -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,
]);
});
});
+390 -25
View File
@@ -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 ?? {}),
};
}),
};
};
+20 -3
View File
@@ -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>
+454 -156
View File
@@ -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[];
+2 -2
View File
@@ -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' }],
});
});
});
+435
View File
@@ -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',
}),
],
});
});
});
+73
View File
@@ -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;
}