This commit is contained in:
Dominic Ferrando
2025-09-15 14:38:57 -04:00
parent 8a251f95c4
commit 5b7d15be9a
4 changed files with 249 additions and 6010 deletions
+234 -184
View File
@@ -1,5 +1,4 @@
import { Activity, Attribute, attributes, ActivityPerformance, getActivityAttribute, getAttributeActivities, Player, StandardsMap, Levels, Standard, Metrics } from "./util"; import { Activity, Attribute, attributes, ActivityPerformance, getActivityAttribute, getAttributeActivities, Player, StandardsMap, Levels, Standard, Metrics, Gender } from "./util";
import rawStandards from "./standards.json" assert { type: "json" }
// SOURCES // SOURCES
// Squat, Bench, Dead Lift: // Squat, Bench, Dead Lift:
@@ -18,24 +17,164 @@ type LevelCalculatorConfig = {
export class LevelCalculator { export class LevelCalculator {
cfg: Required<LevelCalculatorConfig>; cfg: Required<LevelCalculatorConfig>;
standardsMap: StandardsMap; standards: Standards;
public constructor(cfg: LevelCalculatorConfig = {}) { public constructor(standards: Standards, cfg: LevelCalculatorConfig = {}) {
// initialize config
this.cfg = { this.cfg = {
compressTo: cfg.compressTo ?? 100, compressTo: cfg.compressTo ?? 100,
expandIters: cfg.expandIters ?? 5 expandIters: cfg.expandIters ?? 5
} }
this.standards = standards;
}
// initialize standards map // -------------------------------------------------------------------------------------------------
this.standardsMap = rawStandards as StandardsMap; // Level calculations (public API)
for (const activity of Object.keys(this.standardsMap) as Activity[]) { // -------------------------------------------------------------------------------------------------
for (const standard of this.standardsMap[activity]) {
const expandedLevels = this.expandLevels(standard.levels, this.cfg.expandIters); public calculatePlayerLevel(player: Player, activityPerformances: ActivityPerformance[]): number {
const compressedLevels = this.compressLevels(expandedLevels, this.cfg.compressTo); for (const value of Object.values(player)) {
standard.levels = compressedLevels; if (!value)
} return 0;
} }
const attrLevels = this.calculateAllAttributeLevels(player, activityPerformances);
for (const level of Object.values(attrLevels)) {
if (!level)
return 0;
}
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0);
return Math.round(attrLevelsSum / Object.keys(attributes).length);
}
public calculateAllAttributeLevels(
player: Player,
activityPerformances: ActivityPerformance[],
): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
// [attributes.SPEED]: 0,
[attributes.AGILITY]: 0,
} as const;
for (const value of Object.values(player)) {
if (!value)
return attrLevels;
}
(Object.values(attributes) as Attribute[]).forEach((attribute) => {
const attrActivityPerformances = activityPerformances.filter(
(p) => getActivityAttribute(p.activity) === attribute,
);
attrLevels[attribute] = this.calculateAttributeLevel(attribute, player, attrActivityPerformances);
});
return attrLevels;
}
public calculateAttributeLevel(
attribute: Attribute,
player: Player,
activityPerformances: ActivityPerformance[],
): number {
// must be activities of the given attribute
if (activityPerformances.some((p) => getActivityAttribute(p.activity) !== attribute)) {
throw new Error("Wrong activity performance attribute");
}
// must perform all of an attributes activities
for (const activity of getAttributeActivities(attribute)) {
const filteredActivites = activityPerformances.map((p) => p.activity);
if (!filteredActivites.includes(activity))
return 0;
}
// must have all activties using a valid performance value (> 0)
for (const activityPerformance of activityPerformances) {
if (activityPerformance.performance <= 0)
return 0
}
const activityLevels = activityPerformances.map((p) => {
const interpolatedStandard = this.getInterpolatedStandard(p.activity, player.metrics);
return this.findLevel(interpolatedStandard, p.performance);
});
const activityLevelsAvg = Math.round(
activityLevels.reduce((sum, curr) => sum + curr, 0) / activityLevels.length,
);
return activityLevelsAvg;
}
// -------------------------------------------------------------------------------------------------
// Standards interpolation (heavy math bits)
// -------------------------------------------------------------------------------------------------
private getInterpolatedStandard(
activity: Activity,
metrics: Metrics,
): Standard {
const interpolateByWeight = (targetAge: number): Levels => {
// check if this is a weight calibrated activity
const standard = this.standards.byActivity(activity).getOne();
if (standard.metrics.weight === -1) return standard.levels;
const { upper: upperAgeWeightStandard, lower: lowerAgeWeightStandard } = this.standards
.byActivity(activity)
.byGender(metrics.gender)
.byAge(targetAge)
.getNearest("weight", metrics.weight);
const weightLower = lowerAgeWeightStandard.metrics.weight;
const weightUpper = upperAgeWeightStandard.metrics.weight;
let weightRatio = weightUpper === weightLower ? 1 : (metrics.weight - weightLower) / (weightUpper - weightLower);
weightRatio = Math.max(0, Math.min(1, weightRatio));
return this.interpolateLevels(lowerAgeWeightStandard.levels, upperAgeWeightStandard.levels, weightRatio);
}
// Find nearest age standards
const { lower: lowerAgeStandard, upper: upperAgeStandard } = this.standards
.byActivity(activity)
.byGender(metrics.gender)
.getNearest("age", metrics.age);
const ageLower = lowerAgeStandard.metrics.age;
const ageUpper = upperAgeStandard.metrics.age;
// Interpolate by age and weight
let ageRatio = ageUpper === ageLower ? 1 : (metrics.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
const interpolatedLevels = this.interpolateLevels(
interpolateByWeight(ageLower),
interpolateByWeight(ageUpper),
ageRatio
);
return {
metrics: metrics,
levels: interpolatedLevels
};
}
private interpolateLevels(
lower: Levels,
upper: Levels,
ratio: number,
): Levels {
if (Object.keys(lower).length !== Object.keys(upper).length)
throw new Error("Cannot interpolate between varying number of levels");
function lerp(lvl: number): number {
return (lower[lvl] as number) + ((upper[lvl] as number) - (lower[lvl] as number)) * ratio;
}
const interpolatedLevels = Object.keys(lower).map(lvl => lerp(+lvl));
return interpolatedLevels;
} }
// ------------------------------------------------------------------------------------------------- // -------------------------------------------------------------------------------------------------
@@ -55,45 +194,74 @@ export class LevelCalculator {
} }
return resultLevel; return resultLevel;
} }
}
private findNearestStandards(standards: Standard[], target: number, getValue: (x: Metrics) => number) { export class Standards {
const lowerStandard = [...standards] private standardsMap: StandardsMap;
.sort((a, b) => getValue(a.metrics) - getValue(b.metrics))
.reverse() constructor(standardsMap: StandardsMap) {
.find((s) => getValue(s.metrics) <= target) ?? standards.at(0)!; this.standardsMap = standardsMap;
const upperStandard = [...standards] for (const activity of Object.keys(this.standardsMap) as Activity[]) {
.sort((a, b) => getValue(a.metrics) - getValue(b.metrics)) for (const standard of this.standardsMap[activity]) {
.find((s) => getValue(s.metrics) >= target) ?? standards.at(-1)!; const expandedLevels = this.expandLevels(standard.levels, 5);
return { lowerStandard, upperStandard }; const compressedLevels = this.compressLevels(expandedLevels, 100);
standard.levels = compressedLevels;
}
}
} }
private compressLevels( public byActivity(activity: Activity) {
levels: Levels, const self = this;
targetLevelsAmount: number,
): Levels { const state: {
const levelsAmount = Object.keys(levels).length; gender?: Gender;
if (levelsAmount < targetLevelsAmount) { age?: number;
throw new Error( weight?: number;
"Target levels amount must be greater than or equal to the current levels amount", } = {};
);
const execMethods = {
getAll: function() {
let filtered = [...self.standardsMap[activity]];
if (state.gender)
filtered = filtered.filter((s) => s.metrics.gender === state.gender);
if (state.age)
filtered = filtered.filter((s) => s.metrics.age === state.age);
if (state.weight)
filtered = filtered.filter((s) => s.metrics.weight === state.weight);
return filtered;
},
getOne: function() {
return execMethods.getAll()[0];
},
getNearest(metric: "age" | "weight", target: number) {
const standards = execMethods.getAll();
const lower = [...standards]
.sort((a, b) => a.metrics[metric] - b.metrics[metric])
.reverse()
.find((s) => s.metrics[metric] <= target) ?? standards.at(0)!;
const upper = [...standards]
.sort((a, b) => a.metrics[metric] - b.metrics[metric])
.find((s) => s.metrics[metric] >= target) ?? standards.at(-1)!;
return { lower, upper };
}
};
const byGender = (gender: Gender) => {
state.gender = gender;
return { byAge, ...execMethods };
} }
const ratio = levelsAmount / targetLevelsAmount; const byAge = (age: number) => {
const compressedLevels: Levels = {}; state.age = age;
return { byWeight, ...execMethods };
for (let i = 0; i < targetLevelsAmount; ++i) {
const ratioIndex = i * ratio;
const lowerIndex = Math.floor(ratioIndex);
const upperIndex = Math.ceil(ratioIndex);
const lowerValue = levels[lowerIndex + 1];
const upperValue = levels[upperIndex + 1];
const weight = ratioIndex - lowerIndex;
compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
} }
return compressedLevels; const byWeight = (weight: number) => {
state.weight = weight;
return { ...execMethods };
}
return { byGender, ...execMethods }
} }
private expandLevels( private expandLevels(
@@ -119,150 +287,32 @@ export class LevelCalculator {
return this.expandLevels(newLevels, i - 1); return this.expandLevels(newLevels, i - 1);
} }
// ------------------------------------------------------------------------------------------------- private compressLevels(
// Standards interpolation (heavy math bits) levels: Levels,
// ------------------------------------------------------------------------------------------------- targetLevel: number,
private interpolateLevels(
lower: Levels,
upper: Levels,
ratio: number,
): Levels { ): Levels {
if (Object.keys(lower).length !== Object.keys(upper).length) const levelsAmount = Object.keys(levels).length;
throw new Error("Cannot interpolate between varying number of levels"); if (levelsAmount < targetLevel) {
throw new Error(
function lerp(lvl: number): number { "Target levels amount must be greater than or equal to the current levels amount",
return (lower[lvl] as number) + ((upper[lvl] as number) - (lower[lvl] as number)) * ratio;
}
const interpolatedLevels = Object.keys(lower).map(lvl => lerp(+lvl));
return interpolatedLevels;
}
private getInterpolatedActivityStandard(
activity: Activity,
metrics: Metrics,
): Standard {
const interpolatedStandard: Standard = {
metrics: metrics,
levels: {}
};
// Filter by gender
const standardsByGender = this.standardsMap[activity]
.filter(a => a.metrics.gender === metrics.gender);
if (standardsByGender.length === 0) return interpolatedStandard;
// Find nearest age standards
const { lowerStandard, upperStandard } = this.findNearestStandards(standardsByGender, metrics.age, m => m.age);
const ageLower = lowerStandard.metrics.age;
const ageUpper = upperStandard.metrics.age;
// Interpolate by weight
const interpolateByWeight = (targetAge: number): Levels => {
const ageFilteredStandards = standardsByGender.filter((s) => s.metrics.age === targetAge);
if (ageFilteredStandards[0].metrics.weight === -1) return ageFilteredStandards[0].levels;
const { upperStandard, lowerStandard } = this.findNearestStandards(ageFilteredStandards, metrics.weight, m => m.weight);
const weightLower = lowerStandard.metrics.weight;
const weightUpper = upperStandard.metrics.weight;
let weightRatio = weightUpper === weightLower ? 1 : (metrics.weight - weightLower) / (weightUpper - weightLower);
weightRatio = Math.max(0, Math.min(1, weightRatio));
return this.interpolateLevels(lowerStandard.levels, upperStandard.levels, weightRatio);
}
// Interpolate by age
let ageRatio = ageUpper === ageLower ? 1 : (metrics.age - ageLower) / (ageUpper - ageLower);
ageRatio = Math.max(0, Math.min(1, ageRatio));
interpolatedStandard.levels = this.interpolateLevels(
interpolateByWeight(ageLower),
interpolateByWeight(ageUpper),
ageRatio
);
return interpolatedStandard;
}
// -------------------------------------------------------------------------------------------------
// Level calculations (public API)
// -------------------------------------------------------------------------------------------------
public calcAttributeLevel(
attribute: Attribute,
player: Player,
activityPerformances: ActivityPerformance[],
): number {
// must be activities of the given attribute
if (activityPerformances.some((p) => getActivityAttribute(p.activity) !== attribute)) {
throw new Error("Wrong activity performance attribute");
}
// must perform all of an attributes activities
for (const activity of getAttributeActivities(attribute)) {
const filteredActivites = activityPerformances.map((p) => p.activity);
if (!filteredActivites.includes(activity))
return 0;
}
// must have all activties using a valid performance value (> 0)
for (const activityPerformance of activityPerformances) {
if (activityPerformance.performance <= 0)
return 0
}
const activityLevels = activityPerformances.map((p) => {
const interpolatedStandard = this.getInterpolatedActivityStandard(p.activity, player.metrics);
return this.findLevel(interpolatedStandard, p.performance);
});
const activityLevelsAvg = Math.round(
activityLevels.reduce((sum, curr) => sum + curr, 0) / activityLevels.length,
);
return activityLevelsAvg;
}
public calcAllAttributeLevels(
player: Player,
activityPerformances: ActivityPerformance[],
): Record<Attribute, number> {
const attrLevels: Record<Attribute, number> = {
[attributes.STRENGTH]: 0,
[attributes.POWER]: 0,
[attributes.ENDURANCE]: 0,
// [attributes.SPEED]: 0,
[attributes.AGILITY]: 0,
} as const;
for (const value of Object.values(player)) {
if (!value)
return attrLevels;
}
(Object.values(attributes) as Attribute[]).forEach((attribute) => {
const attrActivityPerformances = activityPerformances.filter(
(p) => getActivityAttribute(p.activity) === attribute,
); );
attrLevels[attribute] = this.calcAttributeLevel(attribute, player, attrActivityPerformances);
});
return attrLevels;
}
public calcPlayerLevel(player: Player, activityPerformances: ActivityPerformance[]): number {
for (const value of Object.values(player)) {
if (!value)
return 0;
} }
const attrLevels = this.calcAllAttributeLevels(player, activityPerformances); const ratio = levelsAmount / targetLevel;
for (const level of Object.values(attrLevels)) { const compressedLevels: Levels = {};
if (!level)
return 0; for (let i = 0; i < targetLevel; ++i) {
const ratioIndex = i * ratio;
const lowerIndex = Math.floor(ratioIndex);
const upperIndex = Math.ceil(ratioIndex);
const lowerValue = levels[lowerIndex + 1];
const upperValue = levels[upperIndex + 1];
const weight = ratioIndex - lowerIndex;
compressedLevels[i + 1] = lowerValue + (upperValue - lowerValue) * weight;
} }
const attrLevelsSum = Object.values(attrLevels).reduce((sum, lvl) => sum + lvl, 0); return compressedLevels;
return Math.round(attrLevelsSum / Object.keys(attributes).length);
} }
} }
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,5 +1,6 @@
import { activities, ActivityPerformance, feetToCm, genders, inchesToCm, lbToKg, minToMs, Player, secToMs } from "./util"; import { activities, ActivityPerformance, feetToCm, genders, inchesToCm, lbToKg, minToMs, Player, secToMs, StandardsMap } from "./util";
import { LevelCalculator } from "./calc.ts"; import { LevelCalculator, Standards } from "./calc.ts";
import rawStandards from "../data/standards.json" assert { type: "json" }
const player: Player = { const player: Player = {
metrics: { metrics: {
@@ -40,13 +41,12 @@ const computedPerformances: ActivityPerformance[] = [
}, },
]; ];
console.log(Bun.argv); const standards = new Standards(rawStandards as StandardsMap);
// const levelCalculator = new LevelCalculator({ compressTo: +Bun.argv[2], expandIters: +Bun.argv[3] }); const levelCalculator = new LevelCalculator(standards);
const levelCalculator = new LevelCalculator();
const levels = { const levels = {
attributes: levelCalculator.calcAllAttributeLevels(player, computedPerformances), attributes: levelCalculator.calculateAllAttributeLevels(player, computedPerformances),
player: levelCalculator.calcPlayerLevel(player, computedPerformances) player: levelCalculator.calculatePlayerLevel(player, computedPerformances)
}; };
console.log(levels); console.log(levels);
+8 -5
View File
@@ -1,7 +1,8 @@
import { LevelCalculator } from "./calculator/calc.ts"; import { LevelCalculator, Standards } from "./calculator/calc.ts";
import { ActivityPerformance, Player } from "./calculator/util.ts"; import { ActivityPerformance, Player, StandardsMap } from "./calculator/util.ts";
import { Printful } from "./commerce/printful.ts" import { Printful } from "./commerce/printful.ts"
import { Webflow } from "./commerce/webflow.ts"; import { Webflow } from "./commerce/webflow.ts";
import rawStandards from "./data/standards.json" assert { type: "json" }
interface CalcRequest { interface CalcRequest {
player: Player, player: Player,
@@ -25,6 +26,8 @@ export class ClientResponse extends Response {
} }
} }
const MAIN_STANDARDS = new Standards(rawStandards as StandardsMap);
const server = Bun.serve({ const server = Bun.serve({
idleTimeout: 180, idleTimeout: 180,
routes: { routes: {
@@ -36,12 +39,12 @@ const server = Bun.serve({
const calcReq: CalcRequest = await req.json(); const calcReq: CalcRequest = await req.json();
const { player, activityPerformances } = calcReq; const { player, activityPerformances } = calcReq;
const levelCalculator = new LevelCalculator(); const levelCalculator = new LevelCalculator(MAIN_STANDARDS);
return ClientResponse.json({ return ClientResponse.json({
levels: { levels: {
attributes: levelCalculator.calcAllAttributeLevels(player, activityPerformances), attributes: levelCalculator.calculateAllAttributeLevels(player, activityPerformances),
player: levelCalculator.calcPlayerLevel(player, activityPerformances) player: levelCalculator.calculatePlayerLevel(player, activityPerformances)
} }
}) })
} }