diff --git a/apps/api/src/database/migrations/2026-06-03.ts b/apps/api/src/database/migrations/2026-06-03.ts index ea69ead..e0a0bc2 100644 --- a/apps/api/src/database/migrations/2026-06-03.ts +++ b/apps/api/src/database/migrations/2026-06-03.ts @@ -30,7 +30,7 @@ export async function up(db: Kysely): Promise { // TABLE: CALCULATOR_CONFIGS await db.schema.createTable("calculator_configs") .$call(addDefaultColumns) - .addColumn("parameters", "jsonb", (cb) => cb.notNull()) + .addColumn("standards_config", "jsonb", (cb) => cb.notNull()) .addColumn("is_selected", "boolean", (cb) => cb.notNull()) .execute(); diff --git a/apps/api/src/scripts/analyze.ts b/apps/api/src/scripts/analyze.ts index 4a4fb39..d244725 100644 --- a/apps/api/src/scripts/analyze.ts +++ b/apps/api/src/scripts/analyze.ts @@ -2,7 +2,7 @@ import { type Levels, DEFAULT_STANDARDS_DATA, } from "@blade-and-brawn/calculator"; -import { Activity, clamp, Gender, range } from "@blade-and-brawn/calculator"; +import { Activity, clamp, Gender, range } from "@blade-and-brawn/domain"; import { levenbergMarquardt as LM } from "ml-levenberg-marquardt"; for (const activity of Object.values(Activity)) { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b2b9afd..a44b175 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -114,7 +114,10 @@ export const app = new Elysia() return { sessionId: token.sessionId.toString() }; }) .post("/config", () => { }) - .get("/config", () => { }) + .get("/config", async () => { + const config = await calculatorService.getConfig(); + return config; + }) ) // COMMERCE diff --git a/apps/api/src/services/calculator.ts b/apps/api/src/services/calculator.ts index 8da9179..62610a2 100644 --- a/apps/api/src/services/calculator.ts +++ b/apps/api/src/services/calculator.ts @@ -2,31 +2,49 @@ import { LevelCalculator, Standards, DEFAULT_STANDARDS_DATA, type StandardsConfi import type { ActivityPerformance, Player } from "@blade-and-brawn/domain"; import { db } from "../database/db"; import { Value } from "@sinclair/typebox/value"; +import { log } from "../util"; export class CalculatorService { - private readonly levelCalculator; + private static readonly CONFIG_REFRESH_INTERVAL_MS = 30_000; + + private cachedStandardsConfigData: string | null = null; + private lastRefreshedAt = 0; + + private levelCalculator: LevelCalculator; constructor() { - this.levelCalculator = new LevelCalculator( - new Standards(DEFAULT_STANDARDS_DATA), - ); + this.levelCalculator = new LevelCalculator(new Standards(DEFAULT_STANDARDS_DATA)); } calculate(player: Player, activityPerformances: ActivityPerformance[]) { - return this.levelCalculator.calculate(player, activityPerformances) + this.refreshConfig({ onlyIfStale: true }).catch((err) => log.error({ err }, "failed to refresh calculator config")); + return this.levelCalculator.calculate(player, activityPerformances); } async getConfig(opt: { skipCache?: boolean } = {}): Promise { if (!opt.skipCache) return this.levelCalculator.standards.cfg; + await this.refreshConfig(); + return this.levelCalculator.standards.cfg; + } + + async refreshConfig(opt: { onlyIfStale?: boolean } = {}) { + if (opt.onlyIfStale) + if (Date.now() - this.lastRefreshedAt < CalculatorService.CONFIG_REFRESH_INTERVAL_MS) return; + + this.lastRefreshedAt = Date.now(); + const result = await db.selectFrom("calculator_configs") - .select(["parameters"]) + .select(["standards_config"]) .where("is_selected", "=", true) .limit(1) .executeTakeFirstOrThrow({ errorConstructor: () => new Error("No calculator config selected") }); + Value.Assert(StandardsConfigSchema, result.standards_config); - const standardsConfig = result.parameters; - Value.Assert(StandardsConfigSchema, standardsConfig); - return standardsConfig; + const standardsConfigData = JSON.stringify(result.standards_config); + if (standardsConfigData === this.cachedStandardsConfigData) return; + + this.levelCalculator = new LevelCalculator(new Standards(DEFAULT_STANDARDS_DATA, result.standards_config)); + this.cachedStandardsConfigData = standardsConfigData; } } diff --git a/packages/calculator/src/config.json b/packages/calculator/src/config.json deleted file mode 100644 index a71140c..0000000 --- a/packages/calculator/src/config.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "global": { - "maxLevel": 100 - }, - "activity": { - "BackSquat": { - "enableGeneration": true, - "weightModifier": 0, - "weightSkew": 0, - "ageModifier": 0, - "difficultyModifier": 0.3, - "peakAge": 0, - "stretch": { - "upper": 0, - "lower": 1 - } - }, - "BenchPress": { - "enableGeneration": true, - "weightModifier": 0, - "weightSkew": 0, - "ageModifier": 0, - "difficultyModifier": 0.05, - "peakAge": 0, - "stretch": { - "upper": 1, - "lower": 0 - } - }, - "Deadlift": { - "enableGeneration": true, - "weightModifier": 0, - "weightSkew": 0, - "ageModifier": 0, - "difficultyModifier": -0.05, - "peakAge": 0, - "stretch": { - "upper": 1, - "lower": 0 - } - }, - "Run": { - "enableGeneration": true, - "weightModifier": 0.1, - "weightSkew": 0, - "ageModifier": 0, - "difficultyModifier": 0.35, - "peakAge": 0, - "stretch": { - "upper": 1, - "lower": 1 - } - }, - "BroadJump": { - "enableGeneration": true, - "weightModifier": -0.1, - "weightSkew": 0, - "ageModifier": -0.25, - "difficultyModifier": -0.15, - "peakAge": 23, - "stretch": { - "upper": 3, - "lower": 1 - } - }, - "ConeDrill": { - "enableGeneration": true, - "weightModifier": -0.1, - "weightSkew": 0, - "ageModifier": -0.25, - "difficultyModifier": 0.15, - "peakAge": 23, - "stretch": { - "upper": 0, - "lower": 2 - } - } - } -} diff --git a/packages/calculator/src/index.ts b/packages/calculator/src/index.ts index 83a1dbb..83f6c7d 100644 --- a/packages/calculator/src/index.ts +++ b/packages/calculator/src/index.ts @@ -12,10 +12,10 @@ import { } from "@blade-and-brawn/domain"; import { levenbergMarquardt as LM } from "ml-levenberg-marquardt"; import { Type, type Static } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; -import defaultConfig from "./config.json" with { type: "json" }; -import defaultStandardsJson from "./data/default-standards.json" with { type: "json" }; +import defaultConfig from "./default-config.json" with { type: "json" }; +import defaultStandardsData from "./data/default-standards.json" with { type: "json" }; import { getAvgWeight } from "./avg-weights"; +import { Value } from "@sinclair/typebox/value"; // SOURCES // Squat, Bench, Dead Lift: @@ -64,7 +64,7 @@ const metricPriority = (m: NumberMetric) => { return 2; }; -export const DEFAULT_STANDARDS_DATA = defaultStandardsJson as StandardsData; +export const DEFAULT_STANDARDS_DATA = defaultStandardsData as StandardsData; export class LevelCalculator { readonly standards: Standards; @@ -240,7 +240,8 @@ export class Standards { private standardsData: StandardsData; constructor(standardsData: StandardsData, cfg?: Partial) { - this.cfg = Object.assign(defaultConfig, cfg); + Value.Assert(StandardsConfigSchema, defaultConfig); + this.cfg = Object.assign({}, defaultConfig, cfg); // prepare data this.standardsData = structuredClone(standardsData);