feat: defer advanced build calculations
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 54s

This commit is contained in:
jb
2026-08-02 03:07:12 -04:00
parent c61b9832dd
commit d09b6220ee
19 changed files with 1485 additions and 1267 deletions
+54 -416
View File
@@ -25,20 +25,20 @@ import {
type SkillCalculationRules,
type SkillModifierWindow,
} from '$lib/calculations/skill';
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
import { gearSlots, type LoadoutDraft } from '$lib/builder/loadout';
import {
applyAttributeValue,
catalogAttribute as attribute,
selectedFocus,
selectedNodeRanks,
summarizeLoadout,
type LoadoutCatalog,
type LoadoutSummary,
} from '$lib/builder/loadout-summary';
import type { WeaponCalculationResult } from '$lib/components/builder/WeaponCalculationResult.svelte';
type Catalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
calculation: {
type Catalog = LoadoutCatalog & {
calculation?: {
revision: any | null;
stages: any[];
profiles: any[];
@@ -46,380 +46,16 @@ type Catalog = {
};
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
activeBonuses: any[];
equippedTalents: any[];
};
export { summarizeLoadout } from '$lib/builder/loadout-summary';
export type { LoadoutSummary } from '$lib/builder/loadout-summary';
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
function selectedFocus(draft: LoadoutDraft, catalog: Catalog) {
return catalog.specializations
.flatMap((specialization) =>
specialization.focuses.map((focus: any) => ({
...focus,
specialization,
})),
)
.find((focus: any) => focus.id === draft.specialization.focusId);
}
function selectedNodeRanks(draft: LoadoutDraft, catalog: Catalog) {
const focus = selectedFocus(draft, catalog);
return (
focus?.nodes.flatMap((node: any) => {
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return selectedRank
? [
{
node,
rank: node.ranks.find(
(entry: any) => Number(entry.rank) === selectedRank,
),
},
]
: [];
}) ?? []
);
}
function attribute(catalog: Catalog, id: string) {
return catalog.attributes.find((item) => item.id === id);
}
function applyAttributeValue(
catalog: Catalog,
current: number,
statId: string,
incoming: number,
) {
const stat = attribute(catalog, statId);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
return stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value));
}
function unitValue(catalog: Catalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
);
}
function talentDescription(catalog: Catalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
if (!item || !configured) return '';
const template = String(item.template || item.name);
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = configured.effects[Number(raw) - 1];
return effect
? unitValue(catalog, effect.value, effect.stat_attribute_id)
: placeholder;
});
}
export function summarizeLoadout(
draft: LoadoutDraft,
catalog: Catalog,
): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (!statId || !Number.isFinite(Number(value))) return;
const stat = attribute(catalog, statId);
const current = values.get(statId) ?? 0;
const incoming = Number(value);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
values.set(
statId,
stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value)),
);
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
const calculationCatalog = (catalog: Catalog) =>
catalog.calculation ?? {
revision: null,
stages: [],
profiles: [],
skillProfiles: [],
};
const byName = (name: string) =>
catalog.attributes.find(
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
for (const slot of gearSlots) {
const selection = draft.gear[slot];
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
if (!item) continue;
const enabledAugments = catalog.gearAugments.filter(
(entry) =>
entry.gear_slot === slot &&
selection.augmentSlots.includes(Number(entry.augment_slot)),
);
const basePercentage = enabledAugments
.filter((entry) => entry.effect_type === 'Base Percentage')
.reduce((total, entry) => total + Number(entry.value), 0);
add(
byName('Health'),
selection.health * (1 + basePercentage / 100),
`${slot} base`,
);
if (item.base_armor_min !== null)
add(
byName('Armor'),
selection.armor * (1 + basePercentage / 100),
`${slot} base`,
);
for (const roll of selection.rolls)
add(roll.statId, roll.value, `${slot} roll`);
for (const key of coreKeys)
add(byName(key), selection[key], `${slot} core`);
if (selection.brandId)
brandCounts.set(
selection.brandId,
(brandCounts.get(selection.brandId) ?? 0) + 1,
);
for (const augment of enabledAugments.filter(
(entry) => entry.effect_type === 'Custom',
)) {
add(
augment.stat_attribute_id,
augment.value,
`${slot} augment ${augment.augment_slot}`,
);
}
if (selection.talentId) {
equippedTalents.push({
key: `gear:${slot}:${selection.talentId}`,
id: selection.talentId,
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
),
});
}
}
for (const [brandId, count] of brandCounts) {
const set = catalog.brands.find((entry) => entry.id === brandId);
for (const effect of set?.effects.filter(
(entry: any) => Number(entry.pieces_required) <= count,
) ?? []) {
add(
effect.stat_attribute_id,
effect.value,
`${set.name} ${effect.pieces_required}-piece`,
);
activeBonuses.push({
name: set.name,
pieces: effect.pieces_required,
count,
statId: effect.stat_attribute_id,
value: effect.value,
});
}
}
for (let index = 0; index < 2; index += 1) {
const selection = draft.weapons[index];
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
if (!item) continue;
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
add(
byName(name),
selection[name.toLowerCase() as 'firepower'],
`${index ? 'Secondary' : 'Primary'} weapon`,
);
}
for (const bonus of item.baseBonuses ?? [])
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
for (const roll of selection.rolls)
add(
roll.statId,
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
>();
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
const value = scaled(
group.item.twoPiece.min_value,
group.item.twoPiece.max_value,
group.highEnd,
3,
);
add(
group.item.twoPiece.stat_attribute_id,
value,
`${group.item.name} 2-piece`,
);
activeBonuses.push({
name: group.item.name,
pieces: 2,
count: group.count,
statId: group.item.twoPiece.stat_attribute_id,
value,
});
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = group.item.talent.effects[Number(raw) - 1];
return effect
? unitValue(
catalog,
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
effect.stat_attribute_id,
)
: placeholder;
},
),
});
}
}
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
for (const effect of rank?.effects ?? []) {
if (effect.effect_type === 'Attribute')
add(
effect.stat_attribute_id,
effect.value,
`${node.name} rank ${rank.rank}`,
);
}
}
const protocol = catalog.protocols.find(
(entry) => entry.id === draft.protocol.itemId,
);
if (protocol) {
if (protocol.primaryEffect) {
add(
protocol.primaryEffect.stat_attribute_id,
Number(protocol.primaryEffect.base_value) *
(1 + (draft.protocol.level - 1) / 49),
`${protocol.name} primary`,
);
}
for (const effect of protocol.secondaryEffects ?? []) {
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
}
if (protocol.talent) {
equippedTalents.push({
key: `protocol:${protocol.id}`,
id: '',
custom: protocol.talent,
rule: protocol.talent.rule ?? null,
tier: draft.protocol.talentUpgrades,
source: 'OS Protocol',
description: protocol.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = protocol.talent.effects[Number(raw) - 1];
return effect
? unitValue(
catalog,
Number(effect.base_value) *
(1 + draft.protocol.talentUpgrades / 5),
effect.stat_attribute_id,
)
: placeholder;
},
),
});
}
}
return {
stats: [...values]
.map(([id, value]) => ({
id,
value,
sourceCount: sources.get(id)?.size ?? 0,
}))
.sort(
(left, right) =>
(attribute(catalog, left.id)?.category ?? '').localeCompare(
attribute(catalog, right.id)?.category ?? '',
) ||
(attribute(catalog, left.id)?.name ?? '').localeCompare(
attribute(catalog, right.id)?.name ?? '',
),
),
activeBonuses,
equippedTalents,
};
}
function calculationStage(
catalog: Catalog,
@@ -441,7 +77,7 @@ function calculationStage(
) {
return stageKey as CalculationStage;
}
const stage = catalog.calculation.stages.find(
const stage = calculationCatalog(catalog).stages.find(
(item) => item.stage_key === stageKey,
);
if (!stage) return null;
@@ -462,8 +98,8 @@ function calculationBindings(
catalog: Catalog,
weapon: any,
): AttributeBinding[] {
return catalog.calculation.profiles
.filter(
return calculationCatalog(catalog)
.profiles.filter(
(item) =>
item.weapon_category === '*' ||
item.weapon_category === weapon?.category,
@@ -1211,25 +847,27 @@ function skillCalculationBindings(catalog: Catalog): SkillAttributeBinding[] {
'cooldown_recovery',
'duration',
]);
return (catalog.calculation.skillProfiles ?? []).flatMap((profile: any) => {
const stage =
profile.stage_key === 'engineering'
? 'skill_damage'
: String(profile.stage_key);
return supportedStages.has(stage)
? [
{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator:
profile.operator === 'multiply'
? ('multiply' as const)
: ('add' as const),
weight: Number(profile.weight ?? 1),
},
]
: [];
});
return (calculationCatalog(catalog).skillProfiles ?? []).flatMap(
(profile: any) => {
const stage =
profile.stage_key === 'engineering'
? 'skill_damage'
: String(profile.stage_key);
return supportedStages.has(stage)
? [
{
attributeId: String(profile.stat_attribute_id),
stage: stage as SkillAttributeBinding['stage'],
operator:
profile.operator === 'multiply'
? ('multiply' as const)
: ('add' as const),
weight: Number(profile.weight ?? 1),
},
]
: [];
},
);
}
function loadoutSkillAbilities(
@@ -1348,7 +986,7 @@ function loadoutSkillDeploymentEvents(
Math.max(1, Number(draft.combatProfile.encounterSeconds)),
{
revisionId: String(
catalog.calculation.revision?.id ?? 'mechanic-timeline',
calculationCatalog(catalog).revision?.id ?? 'mechanic-timeline',
),
bindings,
},
@@ -1539,9 +1177,9 @@ export function evaluateLoadoutCombat(
);
const required = (weaponIndex: number) => {
const missing: string[] = [];
if (!catalog.calculation.revision)
if (!calculationCatalog(catalog).revision)
missing.push('An active ISAC calculation revision');
else if (!catalog.calculation.profiles.length)
else if (!calculationCatalog(catalog).profiles.length)
missing.push('Published attribute calculation profiles');
if (!draft.weapons[weaponIndex].itemId)
missing.push(`${weaponIndex ? 'Secondary' : 'Primary'} weapon`);
@@ -1570,8 +1208,8 @@ export function evaluateLoadoutCombat(
weaponLabel: weaponIndex ? 'SECONDARY WEAPON' : 'PRIMARY WEAPON',
status: 'insufficient',
missing,
revisionLabel: catalog.calculation.revision
? `RULES R${catalog.calculation.revision.version}`
revisionLabel: calculationCatalog(catalog).revision
? `RULES R${calculationCatalog(catalog).revision.version}`
: undefined,
};
}
@@ -1660,8 +1298,8 @@ export function evaluateLoadoutCombat(
directDamageTotal += outcome.appliedValue;
}
}
const calculationStages = catalog.calculation.stages
.map((stage) => calculationStage(catalog, String(stage.stage_key)))
const calculationStages = calculationCatalog(catalog)
.stages.map((stage) => calculationStage(catalog, String(stage.stage_key)))
.filter((stage): stage is CalculationStage => stage !== null);
const result = calculateCombat({
weapon: {
@@ -1693,7 +1331,7 @@ export function evaluateLoadoutCombat(
? `Calculation profile for ${attribute(catalog, item.slice(8))?.name ?? item.slice(8)}`
: item.replaceAll('.', ' '),
),
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
};
}
const timeline = buildWeaponMechanicTimeline(
@@ -1754,7 +1392,7 @@ export function evaluateLoadoutCombat(
mode: source.state.mode,
...mechanicSummary(source.evaluation),
})),
revisionLabel: `RULES R${catalog.calculation.revision.version}`,
revisionLabel: `RULES R${calculationCatalog(catalog).revision.version}`,
};
});
}
@@ -1764,7 +1402,7 @@ export function evaluateLoadoutSkillCombat(
catalog: Catalog,
summary = summarizeLoadout(draft, catalog),
): SkillCalculation {
const revisionId = String(catalog.calculation.revision?.id ?? '');
const revisionId = String(calculationCatalog(catalog).revision?.id ?? '');
const bindings = skillCalculationBindings(catalog);
const boundIds = new Set(bindings.map((binding) => binding.attributeId));
const attributes = Object.fromEntries(
+435
View File
@@ -0,0 +1,435 @@
import { describe, expect, it } from 'vitest';
import { createDraft } from './loadout.js';
import { summarizeLoadout, type LoadoutCatalog } from './loadout-summary.js';
const attribute = (
id: string,
name: string,
options: {
unit?: string;
calculationType?: string;
cap?: number | null;
} = {},
) => ({
id,
name,
category: 'Combat',
unit: options.unit ?? '',
calculation_type: options.calculationType ?? 'Additive',
cap_value: options.cap ?? null,
});
const catalog = (overrides: Partial<LoadoutCatalog> = {}): LoadoutCatalog => ({
attributes: [],
gear: [],
gearAugments: [],
brands: [],
weapons: [],
talents: [],
chipSets: [],
protocols: [],
specializations: [],
...overrides,
});
const stat = (summary: ReturnType<typeof summarizeLoadout>, id: string) =>
summary.stats.find((entry) => entry.id === id);
describe('summarizeLoadout', () => {
it('returns an empty deterministic summary for a partial draft', () => {
expect(summarizeLoadout(createDraft(), catalog())).toEqual({
stats: [],
activeBonuses: [],
equippedTalents: [],
});
});
it('ignores saved rolls that do not reference a known Attribute', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].rolls[0] = { statId: 'unknown', value: 999 };
const summary = summarizeLoadout(
draft,
catalog({
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [],
},
],
}),
);
expect(summary.stats).toEqual([]);
});
it('combines additive and multiplicative Attributes, applies caps, and counts distinct sources', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].rolls = [
{ statId: 'additive', value: 5 },
{ statId: 'multiplicative', value: 20 },
];
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('additive', 'Additive'),
attribute('multiplicative', 'Multiplicative', {
unit: '%',
calculationType: 'Multiplicative',
}),
attribute('capped', 'Capped', { cap: 25 }),
],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [
{ stat_attribute_id: 'additive', value: 10 },
{ stat_attribute_id: 'multiplicative', value: 10 },
{ stat_attribute_id: 'capped', value: 30 },
],
},
],
}),
);
expect(stat(summary, 'additive')).toEqual({
id: 'additive',
value: 15,
sourceCount: 2,
});
expect(stat(summary, 'multiplicative')?.value).toBeCloseTo(32);
expect(stat(summary, 'multiplicative')?.sourceCount).toBe(2);
expect(stat(summary, 'capped')).toEqual({
id: 'capped',
value: 25,
sourceCount: 1,
});
});
it('activates every earned set bonus and records it as a stat source', () => {
const draft = createDraft();
for (const slot of ['Holster', 'Gloves'] as const) {
draft.gear[slot].itemId = `${slot}-gear`;
draft.gear[slot].brandId = 'brand';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('set-stat', 'Set Stat', { unit: '%' })],
gear: [
{ id: 'Holster-gear', tier: 0, base_armor_min: null },
{ id: 'Gloves-gear', tier: 0, base_armor_min: null },
],
brands: [
{
id: 'brand',
name: 'Determinist',
effects: [
{
pieces_required: 1,
stat_attribute_id: 'set-stat',
value: 5,
},
{
pieces_required: 2,
stat_attribute_id: 'set-stat',
value: 10,
},
],
},
],
}),
);
expect(stat(summary, 'set-stat')).toEqual({
id: 'set-stat',
value: 15,
sourceCount: 2,
});
expect(summary.activeBonuses).toEqual([
expect.objectContaining({ pieces: 1, count: 2, value: 5 }),
expect.objectContaining({ pieces: 2, count: 2, value: 10 }),
]);
});
it('interpolates Skill Chip bonuses and Talent descriptions from High-end count', () => {
const draft = createDraft();
for (let index = 0; index < 3; index += 1) {
draft.chips[index].setId = 'chip-set';
draft.chips[index].quality = index === 0 ? 'High-end' : 'Superior';
}
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('skill-damage', 'Skill Damage', { unit: '%' })],
chipSets: [
{
id: 'chip-set',
name: 'Signal',
specialization_ability_id: 'ability',
twoPiece: {
stat_attribute_id: 'skill-damage',
min_value: 10,
max_value: 40,
},
talent: {
name: 'Signal Talent',
description_template: 'Gain {1} Skill Damage.',
effects: [
{
stat_attribute_id: 'skill-damage',
min_value: 15,
max_value: 45,
},
],
rule: null,
},
},
],
}),
);
expect(stat(summary, 'skill-damage')).toEqual({
id: 'skill-damage',
value: 20,
sourceCount: 1,
});
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
key: 'chips:chip-set',
tier: 1,
abilityId: 'ability',
description: 'Gain 25% Skill Damage.',
}),
]);
});
it('scales Protocol primary stats and Talent descriptions independently', () => {
const draft = createDraft();
draft.protocol = { itemId: 'protocol', level: 50, talentUpgrades: 5 };
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('health', 'Health'),
attribute('healing', 'Healing', { unit: '%' }),
],
protocols: [
{
id: 'protocol',
name: 'Survival',
primaryEffect: {
stat_attribute_id: 'health',
base_value: 100,
},
secondaryEffects: [{ stat_attribute_id: 'healing', value: 5 }],
talent: {
name: 'Recovery',
description_template: 'Restore {1}.',
effects: [{ stat_attribute_id: 'healing', base_value: 2.5 }],
rule: null,
},
},
],
}),
);
expect(stat(summary, 'health')?.value).toBe(200);
expect(stat(summary, 'healing')?.value).toBe(5);
expect(summary.equippedTalents[0]).toMatchObject({
key: 'protocol:protocol',
tier: 5,
description: 'Restore 5%.',
});
});
it('applies static specialization rank effects but excludes combat mechanic outcomes', () => {
const draft = createDraft();
draft.specialization.focusId = 'focus';
draft.specialization.nodeRanks.node = 2;
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('static', 'Static'),
attribute('mechanic', 'Mechanic'),
],
specializations: [
{
id: 'specialization',
focuses: [
{
id: 'focus',
nodes: [
{
id: 'node',
name: 'Steady Hand',
ranks: [
{
rank: 2,
effects: [
{
effect_type: 'Attribute',
stat_attribute_id: 'static',
value: 12,
},
],
combatEffects: [
{
rule: {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'mechanic',
value: 99,
},
],
},
],
},
},
],
},
],
},
],
},
],
},
],
}),
);
expect(stat(summary, 'static')).toEqual({
id: 'static',
value: 12,
sourceCount: 1,
});
expect(stat(summary, 'mechanic')).toBeUndefined();
});
it('renders the selected Talent tier without applying its mechanic outcomes to stats', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].tier = 2;
draft.weapons[0].talent1Id = 'talent';
const rule = {
clauses: [
{
outcomes: [
{
kind: 'attribute_modifier',
attributeId: 'talent-stat',
value: 100,
},
],
},
],
};
const summary = summarizeLoadout(
draft,
catalog({
attributes: [
attribute('talent-stat', 'Talent Stat', { unit: '%' }),
attribute('shots', 'Shots', { unit: 'Shots' }),
],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
name: 'Measured',
template: 'Gain {1} for {2}.',
tiers: [
{
tier: 'I',
effects: [
{ stat_attribute_id: 'talent-stat', value: 10 },
{ stat_attribute_id: 'shots', value: 2 },
],
},
{
tier: 'II',
effects: [
{ stat_attribute_id: 'talent-stat', value: 20 },
{ stat_attribute_id: 'shots', value: 3 },
],
},
],
rule,
},
],
}),
);
expect(summary.equippedTalents).toEqual([
expect.objectContaining({
id: 'talent',
tier: 2,
description: 'Gain 20% for 3 Shots.',
rule,
}),
]);
expect(stat(summary, 'talent-stat')).toBeUndefined();
});
it('uses the authored tier description when a Talent has no template row', () => {
const draft = createDraft();
draft.weapons[0].itemId = 'weapon';
draft.weapons[0].talent1Id = 'talent';
const summary = summarizeLoadout(
draft,
catalog({
attributes: [attribute('bonus', 'Bonus', { unit: '%' })],
weapons: [
{
id: 'weapon',
name: 'Test Weapon',
exotic_talent_id: null,
baseBonuses: [],
},
],
talents: [
{
id: 'talent',
name: 'Measured',
template: '',
tiers: [
{
tier: 'I',
description: 'Gain {1} while active.',
effects: [{ stat_attribute_id: 'bonus', value: 10 }],
},
],
},
],
}),
);
expect(summary.equippedTalents[0]?.description).toBe(
'Gain 10% while active.',
);
});
});
+396
View File
@@ -0,0 +1,396 @@
import { gearSlots, scaled, type LoadoutDraft } from '$lib/builder/loadout';
export type LoadoutCatalog = {
attributes: any[];
gear: any[];
gearAugments: any[];
brands: any[];
weapons: any[];
talents: any[];
chipSets: any[];
protocols: any[];
specializations: any[];
};
export type LoadoutSummary = {
stats: Array<{ id: string; value: number; sourceCount: number }>;
activeBonuses: any[];
equippedTalents: any[];
};
const coreKeys = ['firepower', 'toughness', 'engineering'] as const;
export function selectedFocus(draft: LoadoutDraft, catalog: LoadoutCatalog) {
return catalog.specializations
.flatMap((specialization) =>
specialization.focuses.map((focus: any) => ({
...focus,
specialization,
})),
)
.find((focus: any) => focus.id === draft.specialization.focusId);
}
export function selectedNodeRanks(
draft: LoadoutDraft,
catalog: LoadoutCatalog,
) {
const focus = selectedFocus(draft, catalog);
return (
focus?.nodes.flatMap((node: any) => {
const selectedRank = Number(draft.specialization.nodeRanks[node.id] ?? 0);
return selectedRank
? [
{
node,
rank: node.ranks.find(
(entry: any) => Number(entry.rank) === selectedRank,
),
},
]
: [];
}) ?? []
);
}
export function catalogAttribute(catalog: LoadoutCatalog, id: string) {
return catalog.attributes.find((item) => item.id === id);
}
export function applyAttributeValue(
catalog: LoadoutCatalog,
current: number,
statId: string,
incoming: number,
) {
const stat = catalogAttribute(catalog, statId);
const combined =
stat?.calculation_type === 'Multiplicative' && stat?.unit === '%'
? ((1 + current / 100) * (1 + incoming / 100) - 1) * 100
: current + incoming;
return stat?.cap_value === null || stat?.cap_value === undefined
? combined
: Math.min(combined, Number(stat.cap_value));
}
function unitValue(catalog: LoadoutCatalog, value: unknown, statId: string) {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? String(numeric)
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = catalogAttribute(catalog, statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
}
function talentTier(catalog: LoadoutCatalog, talentId: string, tier: number) {
const item = catalog.talents.find((entry) => entry.id === talentId);
return (
item?.tiers.find((entry: any) => entry.tier === (tier >= 2 ? 'II' : 'I')) ??
item?.tiers[0]
);
}
function talentDescription(
catalog: LoadoutCatalog,
talentId: string,
tier: number,
) {
const item = catalog.talents.find((entry) => entry.id === talentId);
const configured = talentTier(catalog, talentId, tier);
if (!item || !configured) return '';
const template = String(item.template || configured.description || item.name);
return template.replace(/\{(\d+)\}/g, (placeholder, raw) => {
const effect = configured.effects[Number(raw) - 1];
return effect
? unitValue(catalog, effect.value, effect.stat_attribute_id)
: placeholder;
});
}
export function summarizeLoadout(
draft: LoadoutDraft,
catalog: LoadoutCatalog,
): LoadoutSummary {
const values = new Map<string, number>();
const sources = new Map<string, Set<string>>();
const add = (statId: string, value: unknown, source: string) => {
if (
!statId ||
!catalogAttribute(catalog, statId) ||
!Number.isFinite(Number(value))
)
return;
values.set(
statId,
applyAttributeValue(
catalog,
values.get(statId) ?? 0,
statId,
Number(value),
),
);
if (!sources.has(statId)) sources.set(statId, new Set());
sources.get(statId)!.add(source);
};
const byName = (name: string) =>
catalog.attributes.find(
(item) => item.name.toLocaleLowerCase() === name.toLocaleLowerCase(),
)?.id ?? '';
const brandCounts = new Map<string, number>();
const activeBonuses: any[] = [];
const equippedTalents: any[] = [];
for (const slot of gearSlots) {
const selection = draft.gear[slot];
const item = catalog.gear.find((entry) => entry.id === selection.itemId);
if (!item) continue;
const enabledAugments = catalog.gearAugments.filter(
(entry) =>
entry.gear_slot === slot &&
selection.augmentSlots.includes(Number(entry.augment_slot)),
);
const basePercentage = enabledAugments
.filter((entry) => entry.effect_type === 'Base Percentage')
.reduce((total, entry) => total + Number(entry.value), 0);
add(
byName('Health'),
selection.health * (1 + basePercentage / 100),
`${slot} base`,
);
if (item.base_armor_min !== null)
add(
byName('Armor'),
selection.armor * (1 + basePercentage / 100),
`${slot} base`,
);
for (const roll of selection.rolls)
add(roll.statId, roll.value, `${slot} roll`);
for (const key of coreKeys)
add(byName(key), selection[key], `${slot} core`);
if (selection.brandId)
brandCounts.set(
selection.brandId,
(brandCounts.get(selection.brandId) ?? 0) + 1,
);
for (const augment of enabledAugments.filter(
(entry) => entry.effect_type === 'Custom',
)) {
add(
augment.stat_attribute_id,
augment.value,
`${slot} augment ${augment.augment_slot}`,
);
}
if (selection.talentId) {
equippedTalents.push({
key: `gear:${slot}:${selection.talentId}`,
id: selection.talentId,
rule:
catalog.talents.find((entry) => entry.id === selection.talentId)
?.rule ?? null,
tier: Number(item.tier),
source: slot,
description: talentDescription(
catalog,
selection.talentId,
Number(item.tier),
),
});
}
}
for (const [brandId, count] of brandCounts) {
const set = catalog.brands.find((entry) => entry.id === brandId);
for (const effect of set?.effects.filter(
(entry: any) => Number(entry.pieces_required) <= count,
) ?? []) {
add(
effect.stat_attribute_id,
effect.value,
`${set.name} ${effect.pieces_required}-piece`,
);
activeBonuses.push({
name: set.name,
pieces: effect.pieces_required,
count,
statId: effect.stat_attribute_id,
value: effect.value,
});
}
}
for (let index = 0; index < 2; index += 1) {
const selection = draft.weapons[index];
const item = catalog.weapons.find((entry) => entry.id === selection.itemId);
if (!item) continue;
for (const name of ['Firepower', 'Toughness', 'Engineering'] as const) {
add(
byName(name),
selection[name.toLowerCase() as 'firepower'],
`${index ? 'Secondary' : 'Primary'} weapon`,
);
}
for (const bonus of item.baseBonuses ?? [])
add(bonus.stat_attribute_id, bonus.value, `${item.name} base bonus`);
for (const roll of selection.rolls)
add(
roll.statId,
roll.value,
`${index ? 'Secondary' : 'Primary'} weapon roll`,
);
for (const talentId of [
selection.talent1Id,
selection.talent2Id,
item.exotic_talent_id,
].filter(Boolean)) {
equippedTalents.push({
key: `weapon:${index}:${talentId}`,
id: talentId,
rule:
catalog.talents.find((entry) => entry.id === talentId)?.rule ?? null,
tier: selection.tier,
source: index ? 'Secondary weapon' : 'Primary weapon',
description: talentDescription(catalog, talentId, selection.tier),
});
}
}
const chipGroups = new Map<
string,
{ count: number; highEnd: number; item: any }
>();
for (let index = 0; index < 9; index += 1) {
const selection = draft.chips[index];
const item = catalog.chipSets.find((entry) => entry.id === selection.setId);
if (!item) continue;
add(
selection.primary.statId,
selection.primary.value,
`Skill Chip ${index + 1} primary`,
);
for (const roll of selection.secondary)
add(roll.statId, roll.value, `Skill Chip ${index + 1} secondary`);
const group = chipGroups.get(item.id) ?? { count: 0, highEnd: 0, item };
group.count += 1;
if (selection.quality === 'High-end') group.highEnd += 1;
chipGroups.set(item.id, group);
}
for (const group of chipGroups.values()) {
if (group.count >= 2 && group.item.twoPiece) {
const value = scaled(
group.item.twoPiece.min_value,
group.item.twoPiece.max_value,
group.highEnd,
3,
);
add(
group.item.twoPiece.stat_attribute_id,
value,
`${group.item.name} 2-piece`,
);
activeBonuses.push({
name: group.item.name,
pieces: 2,
count: group.count,
statId: group.item.twoPiece.stat_attribute_id,
value,
});
}
if (group.count === 3 && group.item.talent) {
equippedTalents.push({
key: `chips:${group.item.id}`,
id: '',
custom: group.item.talent,
rule: group.item.talent.rule ?? null,
tier: group.highEnd,
abilityId: group.item.specialization_ability_id ?? null,
source: '3 Skill Chips',
description: group.item.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = group.item.talent.effects[Number(raw) - 1];
return effect
? unitValue(
catalog,
scaled(effect.min_value, effect.max_value, group.highEnd, 3),
effect.stat_attribute_id,
)
: placeholder;
},
),
});
}
}
for (const { node, rank } of selectedNodeRanks(draft, catalog)) {
for (const effect of rank?.effects ?? []) {
if (effect.effect_type === 'Attribute')
add(
effect.stat_attribute_id,
effect.value,
`${node.name} rank ${rank.rank}`,
);
}
}
const protocol = catalog.protocols.find(
(entry) => entry.id === draft.protocol.itemId,
);
if (protocol) {
if (protocol.primaryEffect) {
add(
protocol.primaryEffect.stat_attribute_id,
Number(protocol.primaryEffect.base_value) *
(1 + (draft.protocol.level - 1) / 49),
`${protocol.name} primary`,
);
}
for (const effect of protocol.secondaryEffects ?? []) {
add(effect.stat_attribute_id, effect.value, `${protocol.name} secondary`);
}
if (protocol.talent) {
equippedTalents.push({
key: `protocol:${protocol.id}`,
id: '',
custom: protocol.talent,
rule: protocol.talent.rule ?? null,
tier: draft.protocol.talentUpgrades,
source: 'OS Protocol',
description: protocol.talent.description_template.replace(
/\{(\d+)\}/g,
(placeholder: string, raw: string) => {
const effect = protocol.talent.effects[Number(raw) - 1];
return effect
? unitValue(
catalog,
Number(effect.base_value) *
(1 + draft.protocol.talentUpgrades / 5),
effect.stat_attribute_id,
)
: placeholder;
},
),
});
}
}
return {
stats: [...values]
.map(([id, value]) => ({
id,
value,
sourceCount: sources.get(id)?.size ?? 0,
}))
.sort(
(left, right) =>
(catalogAttribute(catalog, left.id)?.category ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.category ?? '',
) ||
(catalogAttribute(catalog, left.id)?.name ?? '').localeCompare(
catalogAttribute(catalog, right.id)?.name ?? '',
),
),
activeBonuses,
equippedTalents,
};
}
@@ -0,0 +1,105 @@
<script lang="ts">
import GameIcon from '$lib/components/GameIcon.svelte';
import type { GameIconName } from '$lib/game-icons';
import type { LoadoutSummary } from '$lib/builder/loadout-summary';
let {
summary,
attributes,
talents = [],
headingId,
layout = 'rail'
}: {
summary: LoadoutSummary;
attributes: any[];
talents?: any[];
headingId: string;
layout?: 'rail' | 'page';
} = $props();
const attribute = (id: string) => attributes.find((item) => item.id === id);
const talent = (id: string) => talents.find((item) => item.id === id);
const unitValue = (value: unknown, statId: string) => {
const numeric = Number(value);
const rendered = Number.isInteger(numeric)
? numeric.toLocaleString()
: numeric.toFixed(2).replace(/\.?0+$/, '');
const unit = attribute(statId)?.unit ?? '';
return unit === 'Shots' ? `${rendered} ${unit}` : `${rendered}${unit}`;
};
const talentCooldown = (id: string) => {
const value = talent(id)?.cooldown_seconds;
if (value === null || value === undefined || value === '') return '';
return `Cooldown: ${value} ${Number(value) === 1 ? 'second' : 'seconds'}`;
};
</script>
<section class:page={layout === 'page'} class="loadout-known-stats" aria-labelledby={headingId}>
<div class="summary-head">
<div>
<p class="eyebrow"><span></span> KNOWN EQUIPMENT TOTALS</p>
<h2 id={headingId}>Agent stats</h2>
</div>
<b>{summary.stats.length}</b>
</div>
<div class="summary-core">
{#each ['Firepower', 'Toughness', 'Engineering'] as name}
{@const stat = summary.stats.find((entry) => attribute(entry.id)?.name === name)}
<div>
<GameIcon name={name.toLowerCase() as GameIconName} decorative />
<span>{name}<b>{stat ? unitValue(stat.value, stat.id) : '0'}</b></span>
</div>
{/each}
</div>
<div class="summary-scroll">
<div class="summary-section">
<header><span>COMBINED ATTRIBUTES</span><b>{summary.stats.length}</b></header>
{#each summary.stats as stat}
<div class="summary-stat">
<span>{attribute(stat.id)?.name}<small>{attribute(stat.id)?.category} · {stat.sourceCount} SOURCE{stat.sourceCount === 1 ? '' : 'S'}</small></span>
<b>{unitValue(stat.value, stat.id)}</b>
</div>
{:else}
<p>Equip and configure items to calculate their known Attributes.</p>
{/each}
</div>
<div class="summary-section">
<header><span>ACTIVE SET BONUSES</span><b>{summary.activeBonuses.length}</b></header>
{#each summary.activeBonuses as bonus}
<div class="summary-bonus">
<span><b>{bonus.name}</b><small>{bonus.pieces}-PIECE · {bonus.count} EQUIPPED</small></span>
<em>+{unitValue(bonus.value, bonus.statId)} {attribute(bonus.statId)?.name}</em>
</div>
{:else}
<p>Matching Brand Sets and Skill Chips activate bonuses here.</p>
{/each}
</div>
<div class="summary-section">
<header><span>EQUIPPED TALENTS</span><b>{summary.equippedTalents.length}</b></header>
{#each summary.equippedTalents as equipped}
<div class="summary-talent">
<span>
<b>{equipped.custom?.name ?? talent(equipped.id)?.name ?? 'Talent'}</b>
<small>{equipped.source} · CONDITIONAL EFFECT NOT INCLUDED</small>
<em>{equipped.description}</em>
{#if equipped.id && talentCooldown(equipped.id)}<i class="talent-cooldown">{talentCooldown(equipped.id)}</i>{/if}
</span>
</div>
{:else}
<p>Selected equipment Talents appear here.</p>
{/each}
</div>
</div>
<p class="known-stats-note">Totals reflect this saved loadout and the current canonical item definitions. They include fixed values, rolls, inherited profiles, enabled augments, set bonuses, Protocol effects, and static Specialization rank effects. Conditional Talent outcomes are reserved for the advanced combat model.</p>
</section>
<style>
.loadout-known-stats{background:var(--shd-surface)}
.summary-talent{grid-template-columns:1fr;cursor:default}
.known-stats-note{margin:0;padding:11px;border-top:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.45}
.page{border:1px solid var(--shd-border);border-top:2px solid var(--shd-success)}
.page .summary-scroll{max-height:none;display:grid;grid-template-columns:repeat(3,minmax(0,1fr))}
.page .summary-section{border-left:1px solid var(--shd-border)}
.page .summary-section:first-child{border-left:0}
@media(max-width:900px){.page .summary-scroll{grid-template-columns:1fr}.page .summary-section{border-left:0;border-top:1px solid var(--shd-border)}.page .summary-section:first-child{border-top:0}}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
let {
headingId,
variant = 'full',
headingLevel = 'h3'
}: {
headingId: string;
variant?: 'full' | 'compact';
headingLevel?: 'h2' | 'h3';
} = $props();
const capabilities = [
{ label: 'WEAPON OUTPUT', detail: 'Hit values, burst DPS, sustained DPS, and reload-aware firing cycles' },
{ label: 'SKILL SIMULATION', detail: 'Ability cadence, target counts, critical hits, and multi-shot behavior' },
{ label: 'ENCOUNTER MODEL', detail: 'Duration, accuracy, movement, target flow, rotations, and downtime' },
{ label: 'CONDITIONAL EFFECTS', detail: 'Talent triggers, stack buildup, cooldowns, uptime, and resource changes' }
];
</script>
<section class:compact={variant === 'compact'} class="planned-combat-model" aria-labelledby={headingId}>
<header>
<div><span>ADVANCED COMBAT MODEL</span>{#if headingLevel === 'h2'}<h2 id={headingId}>DPS calculations</h2>{:else}<h3 id={headingId}>DPS calculations</h3>{/if}</div>
<b>COMING SOON</b>
</header>
{#if variant === 'full'}
<p>Known equipment totals remain available now. Advanced damage projections will return after their calculation rules and scenario controls are ready for player use.</p>
{/if}
<div class="planned-capabilities" aria-label="Planned combat-model capabilities">
{#each capabilities as capability}
<div><span><strong>{capability.label}</strong><small>{capability.detail}</small></span><em>PLANNED</em></div>
{/each}
</div>
</section>
<style>
.planned-combat-model{min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:var(--shd-gap-md);padding:var(--shd-gap-md);overflow:hidden;border:1px solid var(--shd-model-border);border-top:2px solid var(--shd-info);background:var(--shd-model-surface)}
.planned-combat-model>*{min-width:0}
header{display:flex;align-items:start;justify-content:space-between;gap:var(--shd-gap-md);padding:0}
header div{display:grid;gap:3px}header span{color:var(--shd-info);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:1px}h2,h3{margin:0;color:var(--shd-text);font-size:var(--shd-type-body);letter-spacing:.7px;text-transform:uppercase}header b{padding:5px 7px;border:1px solid var(--shd-info);color:var(--shd-info);font-size:var(--shd-type-telemetry);letter-spacing:.8px;white-space:nowrap}
p{max-width:72ch;margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.5}
.planned-capabilities{width:100%;min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--shd-model-border)}
.planned-capabilities>div{display:flex;align-items:center;justify-content:space-between;gap:var(--shd-gap-sm);min-width:0;padding:9px;border-top:1px solid var(--shd-model-border);border-left:1px solid var(--shd-model-border)}
.planned-capabilities>div:nth-child(-n+2){border-top:0}.planned-capabilities>div:nth-child(odd){border-left:0}
.planned-capabilities span{display:grid;gap:3px;min-width:0}.planned-capabilities strong,.planned-capabilities em{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.planned-capabilities small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);line-height:1.35}.planned-capabilities em{color:var(--shd-text-dim);font-style:normal;white-space:nowrap}
.compact{gap:var(--shd-gap-sm);padding:var(--shd-gap-sm)}.compact .planned-capabilities{grid-template-columns:minmax(0,1fr)}.compact .planned-capabilities>div{border-left:0}.compact .planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}
@media(max-width:620px){header{display:grid}.planned-capabilities{grid-template-columns:1fr}.planned-capabilities>div{min-height:44px;border-left:0}.planned-capabilities>div:nth-child(2){border-top:1px solid var(--shd-model-border)}}
</style>
@@ -793,7 +793,7 @@
<section class="scenario-panel">
<header><div><span>BUILDER CONTROLS</span><strong>SCENARIO ASSUMPTIONS</strong></div><button type="button" class="secondary compact-action" onclick={addScenarioInput}>ADD CONTROL +</button></header>
<p>These controls appear in a build’s advanced mechanic settings. Maximum Potential uses the best-case value; Custom Scenario uses the Agent’s saved value.</p>
<p>These controls are retained for the planned advanced build model. Maximum Potential will use the best-case value; Custom Scenario will use the Agent’s saved value.</p>
{#each rule.scenarioInputs as input, index}
<div class="scenario-row">
<label class="field">LABEL<input value={input.label} oninput={(event) => updateScenarioInput(index,{label:event.currentTarget.value,key:slug(event.currentTarget.value)})}/></label>
-388
View File
@@ -1,388 +0,0 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
export async function loadSharedBuildCombatCatalog(database: Database) {
const [
attributes,
gear,
gearAugments,
brands,
brandEffects,
weapons,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
conditionalRules,
conditionalPools,
conditionalClauses,
conditionalOutcomes,
chipSets,
chipTwoPiece,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
specializations,
specializationFocuses,
specializationAbilities,
specializationAbilityAttacks,
focusAbilities,
specializationNodes,
specializationRanks,
specializationRankEffects,
specializationCombatEffects,
specializationRankCombatEffects,
specializationCombatModifiers,
activeCalculationRevision,
calculationStages,
calculationProfiles,
skillCalculationProfiles,
] = await Promise.all([
(
await database.prepare(
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_rules WHERE configured=1',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM specialization_ability_attacks')
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
)
).all(),
(
await database.prepare(
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
)
).get(),
(
await database.prepare(`SELECT stage.* FROM calculation_stages stage
JOIN calculation_revisions revision ON revision.id=stage.revision_id
WHERE revision.status='active' ORDER BY stage.display_order`)
).all(),
(
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
(
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
JOIN calculation_revisions revision ON revision.id=profile.revision_id
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
).all(),
]);
const hydrateRule = (sourceType: string, sourceId: unknown) => {
const rule = conditionalRules.find(
(item) => item.source_type === sourceType && item.source_id === sourceId,
);
return rule
? {
rule,
statePools: conditionalPools.filter(
(pool) => pool.rule_id === rule.id,
),
clauses: conditionalClauses.filter(
(clause) => clause.rule_id === rule.id,
),
outcomes: conditionalOutcomes.filter(
(outcome) => outcome.rule_id === rule.id,
),
triggers: [],
}
: null;
};
const configuredTalents = talents.map((talent) => ({
...talent,
template:
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
'',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter(
(effect) => effect.talent_tier_id === tier.id,
),
})),
rule: hydrateRule('talent', talent.id),
}));
return {
attributes,
gear,
gearAugments,
brands: brands.map((brand) => ({
...brand,
effects: brandEffects.filter(
(effect) => effect.brand_set_id === brand.id,
),
})),
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: configuredTalents,
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
talent: talent
? {
...talent,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('skill_chip_talent', talent.id),
}
: null,
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find(
(item) =>
item.quality === protocol.quality &&
item.primary_attribute_type === protocol.primary_attribute_type,
);
const talent = protocolTalents.find(
(item) => item.protocol_id === protocol.id,
);
return {
...protocol,
primaryEffect:
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
null,
secondaryEffects: profile
? protocolSecondary.filter(
(effect) => effect.profile_id === profile.id,
)
: [],
talent: talent
? {
...talent,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
rule: hydrateRule('os_protocol_talent', talent.id),
}
: null,
};
}),
specializations: specializations.map((specialization) => ({
...specialization,
focuses: specializationFocuses
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => {
const abilities = focusAbilities
.filter((link) => link.focus_id === focus.id)
.sort(
(left, right) =>
Number(left.ability_slot) - Number(right.ability_slot),
)
.map((link) => {
const ability = specializationAbilities.find(
(entry) => entry.id === link.ability_id,
);
return ability
? {
...ability,
attack:
specializationAbilityAttacks.find(
(attack) => attack.ability_id === ability.id,
) ?? null,
}
: null;
})
.filter(Boolean);
const nodes = specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
...node,
ranks: specializationRanks
.filter((rank) => rank.node_id === node.id)
.map((rank) => ({
...rank,
effects: specializationRankEffects.filter(
(effect) =>
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: specializationCombatEffects.find(
(definition) =>
definition.id === effect.combat_effect_id,
),
modifiers: specializationCombatModifiers.filter(
(modifier) =>
modifier.rank_combat_effect_id === effect.id,
),
rule: hydrateRule('specialization_tree_rank', effect.id),
})),
})),
}));
return { ...focus, abilities, nodes };
}),
})),
calculation: {
revision: activeCalculationRevision ?? null,
stages: calculationStages,
profiles: calculationProfiles,
skillProfiles: skillCalculationProfiles,
},
};
}
+236
View File
@@ -0,0 +1,236 @@
import type { Database } from '@tursodatabase/database';
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
export async function loadSharedBuildSummaryCatalog(database: Database) {
const [
attributes,
gear,
gearAugments,
brands,
brandEffects,
weapons,
weaponBonuses,
talents,
talentTiers,
talentEffects,
talentTemplates,
chipSets,
chipTwoPiece,
chipTalents,
chipTalentEffects,
protocols,
protocolPrimary,
protocolProfiles,
protocolSecondary,
protocolTalents,
protocolTalentEffects,
specializations,
specializationFocuses,
specializationNodes,
specializationRanks,
specializationRankEffects,
] = await Promise.all([
(
await database.prepare(
'SELECT * FROM stat_attributes ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear ORDER BY slot,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapons ORDER BY category,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM talents ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
(
await database.prepare('SELECT * FROM talent_description_templates')
).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_sets ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
).all(),
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
(
await database.prepare(
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM os_protocols ORDER BY name COLLATE NOCASE',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
(
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
)
).all(),
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
(
await database.prepare(
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
)
).all(),
(
await database.prepare(
'SELECT * FROM specializations ORDER BY name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_focuses ORDER BY specialization_id,display_order,name COLLATE NOCASE',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_nodes ORDER BY focus_id,row_number,column_number',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
)
).all(),
(
await database.prepare(
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
)
).all(),
]);
return {
attributes,
gear,
gearAugments,
brands: brands.map((brand) => ({
...brand,
effects: brandEffects.filter(
(effect) => effect.brand_set_id === brand.id,
),
})),
weapons: attachWeaponCategoryBonuses(weapons, weaponBonuses),
talents: talents.map((talent) => ({
...talent,
template:
talentTemplates.find((item) => item.talent_id === talent.id)
?.template ?? '',
tiers: talentTiers
.filter((tier) => tier.talent_id === talent.id)
.map((tier) => ({
...tier,
effects: talentEffects.filter(
(effect) => effect.talent_tier_id === tier.id,
),
})),
})),
chipSets: chipSets.map((set) => {
const talent = chipTalents.find(
(item) => item.skill_chip_set_id === set.id,
);
return {
...set,
twoPiece:
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
null,
talent: talent
? {
...talent,
effects: chipTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
}
: null,
};
}),
protocols: protocols.map((protocol) => {
const profile = protocolProfiles.find(
(item) =>
item.quality === protocol.quality &&
item.primary_attribute_type === protocol.primary_attribute_type,
);
const talent = protocolTalents.find(
(item) => item.protocol_id === protocol.id,
);
return {
...protocol,
primaryEffect:
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
null,
secondaryEffects: profile
? protocolSecondary.filter(
(effect) => effect.profile_id === profile.id,
)
: [],
talent: talent
? {
...talent,
effects: protocolTalentEffects.filter(
(effect) => effect.talent_id === talent.id,
),
}
: null,
};
}),
specializations: specializations.map((specialization) => ({
...specialization,
focuses: specializationFocuses
.filter((focus) => focus.specialization_id === specialization.id)
.map((focus) => ({
...focus,
nodes: specializationNodes
.filter((node) => node.focus_id === focus.id)
.map((node) => ({
...node,
ranks: specializationRanks
.filter((rank) => rank.node_id === node.id)
.map((rank) => ({
...rank,
effects: specializationRankEffects.filter(
(effect) =>
effect.node_id === node.id &&
Number(effect.rank) === Number(rank.rank),
),
})),
})),
})),
})),
};
}