import { Activity, Attribute, attributes, ActivityPerformance, getActivityAttribute, getAttributeActivities, Player, StandardsMap, Levels, Standard, Metrics, Gender } from "./util"; // SOURCES // Squat, Bench, Dead Lift: // http://lonkilgore.com/resources/Lon_Kilgore_Strength_Standard_Tables-Copyright-2023.pdf // 1 mile run: // https://runninglevel.com/running-times/1-mile-times // Dash: // https://marathonhandbook.com/average-100-meter-time/ // Broad Jump: // type LevelCalculatorConfig = { compressTo?: number; expandIters?: number; } export class LevelCalculator { cfg: Required; standards: Standards; public constructor(standards: Standards, cfg: LevelCalculatorConfig = {}) { this.cfg = { compressTo: cfg.compressTo ?? 100, expandIters: cfg.expandIters ?? 5 } this.standards = standards; } // ------------------------------------------------------------------------------------------------- // Level calculations (public API) // ------------------------------------------------------------------------------------------------- public calculatePlayerLevel(player: Player, activityPerformances: ActivityPerformance[]): number { for (const value of Object.values(player)) { 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 { const attrLevels: Record = { [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; } // ------------------------------------------------------------------------------------------------- // Level utilities (helpers) // ------------------------------------------------------------------------------------------------- private findLevel(standard: Standard, performance: number): number { let resultLevel = 1; let resultLevelDiff = Infinity; for (const level in standard.levels) { const diff = Math.abs(performance - standard.levels[level as unknown as number]); if (diff < resultLevelDiff) { resultLevel = +level; resultLevelDiff = diff; } } return resultLevel; } } export class Standards { private standardsMap: StandardsMap; constructor(standardsMap: StandardsMap) { this.standardsMap = standardsMap; for (const activity of Object.keys(this.standardsMap) as Activity[]) { for (const standard of this.standardsMap[activity]) { const expandedLevels = this.expandLevels(standard.levels, 5); const compressedLevels = this.compressLevels(expandedLevels, 100); standard.levels = compressedLevels; } } } public byActivity(activity: Activity) { const self = this; const state: { gender?: Gender; age?: number; weight?: number; } = {}; 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 byAge = (age: number) => { state.age = age; return { byWeight, ...execMethods }; } const byWeight = (weight: number) => { state.weight = weight; return { ...execMethods }; } return { byGender, ...execMethods } } private expandLevels( levels: Levels, i: number ): Levels { if (i == 0) return levels; const newLevels = {}; let j = 1; for (let k = 0; k < Object.keys(levels).length; ++k) { const currLevel = Object.keys(levels)[k]; const nextLevel = Object.keys(levels)[k + 1]; newLevels[j++] = levels[currLevel]; if (nextLevel) { newLevels[j++] = (levels[currLevel] + levels[nextLevel]) / 2; } } return this.expandLevels(newLevels, i - 1); } private compressLevels( levels: Levels, targetLevel: number, ): Levels { const levelsAmount = Object.keys(levels).length; if (levelsAmount < targetLevel) { throw new Error( "Target levels amount must be greater than or equal to the current levels amount", ); } const ratio = levelsAmount / targetLevel; const compressedLevels: Levels = {}; 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; } return compressedLevels; } }