mirror of
https://github.com/williamsjack/pairwise-glicko-ranking.git
synced 2026-07-22 06:44:09 +00:00
430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
import type { App, TFile } from 'obsidian';
|
|
|
|
import type { FrontmatterPropertiesSettings, StarScaleConfig } from '../settings';
|
|
import type { CohortData } from '../types';
|
|
import { getNoteId } from './NoteIds';
|
|
import { withNotice } from './safe';
|
|
|
|
type PlayerStats = {
|
|
rating: number;
|
|
uncertainty: number;
|
|
matches: number;
|
|
wins: number;
|
|
rank: number;
|
|
stars?: number;
|
|
};
|
|
|
|
function anyEnabled(fm: FrontmatterPropertiesSettings): boolean {
|
|
return (
|
|
!!fm.rating.enabled ||
|
|
!!fm.uncertainty.enabled ||
|
|
!!fm.rank.enabled ||
|
|
!!fm.matches.enabled ||
|
|
!!fm.wins.enabled ||
|
|
!!fm.stars.enabled
|
|
);
|
|
}
|
|
|
|
// Compute a derived n-star value for every rated note in the cohort
|
|
export function computeStarsForAll(cohort: CohortData, cfg: StarScaleConfig): Map<string, number> {
|
|
const map = new Map<string, number>();
|
|
const rated = Object.entries(cohort.players)
|
|
.filter(([, p]) => p.matches >= 1)
|
|
.map(([id, p]) => ({ id, rating: p.rating }));
|
|
if (rated.length === 0) return map;
|
|
|
|
const floor = cfg.allowZero ? 0 : 1;
|
|
const top = cfg.max;
|
|
const range = top - floor;
|
|
|
|
const quantize = (value: number): number => {
|
|
const clamped = Math.min(top, Math.max(floor, value));
|
|
if (cfg.mode === 'integer') return Math.round(clamped);
|
|
const places = Math.max(0, Math.min(6, Math.floor(cfg.decimals ?? 1)));
|
|
const factor = Math.pow(10, places);
|
|
return Math.round(clamped * factor) / factor;
|
|
};
|
|
|
|
// Degenerate scale: nothing meaningful to spread across
|
|
if (range <= 0) {
|
|
for (const { id } of rated) map.set(id, quantize(floor));
|
|
return map;
|
|
}
|
|
|
|
const midpoint = floor + range / 2;
|
|
|
|
if (cfg.mapping === 'rank') {
|
|
// Even spread by rank position (1 = best). Standard competition ranking,
|
|
// so tied ratings share the higher rank.
|
|
const sorted = [...rated].sort((a, b) => b.rating - a.rating);
|
|
const m = sorted.length;
|
|
if (m === 1) {
|
|
map.set(sorted[0].id, quantize(midpoint));
|
|
return map;
|
|
}
|
|
let lastRating: number | undefined;
|
|
let rank = 0;
|
|
for (let i = 0; i < m; i++) {
|
|
const { id, rating } = sorted[i];
|
|
if (lastRating === undefined || rating !== lastRating) {
|
|
rank = i + 1;
|
|
lastRating = rating;
|
|
}
|
|
const t = (m - rank) / (m - 1); // best -> 1, worst -> 0
|
|
map.set(id, quantize(floor + t * range));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
for (const { rating } of rated) {
|
|
if (rating < min) min = rating;
|
|
if (rating > max) max = rating;
|
|
}
|
|
if (max === min) {
|
|
for (const { id } of rated) map.set(id, quantize(midpoint));
|
|
return map;
|
|
}
|
|
for (const { id, rating } of rated) {
|
|
const t = (rating - min) / (max - min);
|
|
map.set(id, quantize(floor + t * range));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// Standard competition ranking ("1224" style)
|
|
export function computeRanksForAll(cohort: CohortData): Map<string, number> {
|
|
const entries = Object.entries(cohort.players);
|
|
entries.sort((a, b) => Math.round(b[1].rating) - Math.round(a[1].rating));
|
|
|
|
const map = new Map<string, number>();
|
|
let lastRating: number | undefined = undefined;
|
|
let rank = 0;
|
|
let nextRank = 1;
|
|
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const [id, player] = entries[i];
|
|
const rounded = Math.round(player.rating);
|
|
if (lastRating === undefined || rounded !== lastRating) {
|
|
rank = nextRank;
|
|
lastRating = rounded;
|
|
}
|
|
map.set(id, rank);
|
|
nextRank = i + 2;
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// Compute ranks for a subset of players by counting how many players
|
|
// have a strictly higher rating (standard competition ranking)
|
|
function computeRanksForSubset(cohort: CohortData, playerIds: string[]): Map<string, number> {
|
|
const targets = playerIds
|
|
.map((id) => {
|
|
const r = cohort.players[id]?.rating;
|
|
return { id, rating: r !== undefined ? Math.round(r) : undefined };
|
|
})
|
|
.filter((t): t is { id: string; rating: number } => t.rating !== undefined);
|
|
|
|
if (targets.length === 0) return new Map();
|
|
|
|
// For each player in the cohort, check if their rating exceeds
|
|
// any target's rating and increment that target's rank counter
|
|
const higherCounts = new Map<string, number>(targets.map((t) => [t.id, 0]));
|
|
for (const player of Object.values(cohort.players)) {
|
|
const rounded = Math.round(player.rating);
|
|
for (const t of targets) {
|
|
if (rounded > t.rating) {
|
|
higherCounts.set(t.id, higherCounts.get(t.id)! + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
const rankMap = new Map<string, number>();
|
|
for (const [id, count] of higherCounts) {
|
|
rankMap.set(id, count + 1);
|
|
}
|
|
return rankMap;
|
|
}
|
|
|
|
function buildProps(fm: FrontmatterPropertiesSettings, stats: PlayerStats): Record<string, number> {
|
|
const out: Record<string, number> = {};
|
|
|
|
if (fm.rating.enabled && fm.rating.property) {
|
|
out[fm.rating.property] = Math.round(stats.rating);
|
|
}
|
|
if (fm.uncertainty.enabled && fm.uncertainty.property) {
|
|
out[fm.uncertainty.property] = Math.round(stats.uncertainty);
|
|
}
|
|
if (fm.rank.enabled && fm.rank.property) {
|
|
out[fm.rank.property] = stats.rank;
|
|
}
|
|
if (fm.matches.enabled && fm.matches.property) {
|
|
out[fm.matches.property] = stats.matches;
|
|
}
|
|
if (fm.wins.enabled && fm.wins.property) {
|
|
out[fm.wins.property] = stats.wins;
|
|
}
|
|
if (fm.stars.enabled && fm.stars.property && typeof stats.stars === 'number') {
|
|
out[fm.stars.property] = stats.stars;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function writeProps(app: App, file: TFile, props: Record<string, number>): Promise<void> {
|
|
if (Object.keys(props).length === 0) return;
|
|
await app.fileManager.processFrontMatter(file, (fmRaw) => {
|
|
const fm = fmRaw as Record<string, unknown>;
|
|
for (const [k, v] of Object.entries(props)) {
|
|
fm[k] = v;
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function writeFrontmatterStatsForPair(
|
|
app: App,
|
|
fm: FrontmatterPropertiesSettings,
|
|
cohort: CohortData | undefined,
|
|
aFile?: TFile,
|
|
aId?: string,
|
|
bFile?: TFile,
|
|
bId?: string,
|
|
): Promise<void> {
|
|
if (!cohort) return;
|
|
if (!anyEnabled(fm)) return;
|
|
|
|
const ids = [aId, bId].filter((id): id is string => !!id);
|
|
const rankMap = computeRanksForSubset(cohort, ids);
|
|
// Stars depend on the cohort-wide min/max (or full ranking), so compute the
|
|
// whole map and look up the pair. Other notes are refreshed at session end.
|
|
const starMap =
|
|
fm.stars.enabled && fm.stars.property ? computeStarsForAll(cohort, fm.stars) : undefined;
|
|
const tasks: Promise<void>[] = [];
|
|
|
|
if (aFile && aId) {
|
|
tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, aFile, aId, rankMap, starMap));
|
|
}
|
|
if (bFile && bId) {
|
|
tasks.push(writeFrontmatterStatsForPlayer(app, fm, cohort, bFile, bId, rankMap, starMap));
|
|
}
|
|
|
|
await Promise.all(tasks);
|
|
}
|
|
|
|
type FrontmatterUpdatePlan =
|
|
| { op: 'set'; file: TFile; setKey: string; setValue: number; deleteKey?: string }
|
|
| { op: 'delete'; file: TFile; deleteKey: string };
|
|
|
|
// Shared planner to compute per-file frontmatter updates (set and/or delete).
|
|
async function planFrontmatterUpdates(
|
|
app: App,
|
|
files: TFile[],
|
|
valuesById: Map<string, number>,
|
|
newPropName: string,
|
|
oldPropName?: string,
|
|
idPropertyName?: string,
|
|
): Promise<{ plans: FrontmatterUpdatePlan[] }> {
|
|
const prop = (newPropName ?? '').trim();
|
|
const oldProp = (oldPropName ?? '').trim();
|
|
|
|
const plans: FrontmatterUpdatePlan[] = [];
|
|
|
|
for (const file of files) {
|
|
const id = await getNoteId(app, file, idPropertyName ?? 'glickoId');
|
|
if (!id) continue;
|
|
|
|
const fcRaw: unknown = app.metadataCache.getFileCache(file)?.frontmatter;
|
|
const fmCache =
|
|
fcRaw && typeof fcRaw === 'object' ? (fcRaw as Record<string, unknown>) : undefined;
|
|
|
|
// Removal-only mode: delete oldProp if present.
|
|
if (!prop && oldProp) {
|
|
const hasOld = typeof fmCache?.[oldProp] !== 'undefined';
|
|
if (hasOld) {
|
|
plans.push({ op: 'delete', file, deleteKey: oldProp });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Nothing to do if no new prop.
|
|
if (!prop) continue;
|
|
|
|
// Only consider files that have a value provided for this id.
|
|
const newVal = valuesById.get(id);
|
|
if (typeof newVal === 'undefined') continue;
|
|
|
|
const curNewRaw: unknown = fmCache ? fmCache[prop] : undefined;
|
|
const curNew =
|
|
typeof curNewRaw === 'number'
|
|
? curNewRaw
|
|
: typeof curNewRaw === 'string'
|
|
? parseInt(curNewRaw, 10)
|
|
: undefined;
|
|
|
|
const hasOld = !!oldProp && oldProp !== prop && typeof fmCache?.[oldProp] !== 'undefined';
|
|
|
|
const needSet = curNew !== newVal;
|
|
const needRemoveOld = hasOld;
|
|
|
|
if (needSet || needRemoveOld) {
|
|
if (needSet) {
|
|
plans.push({
|
|
op: 'set',
|
|
file,
|
|
setKey: prop,
|
|
setValue: newVal,
|
|
...(needRemoveOld ? { deleteKey: oldProp } : {}),
|
|
});
|
|
} else {
|
|
plans.push({ op: 'delete', file, deleteKey: oldProp });
|
|
}
|
|
}
|
|
}
|
|
|
|
return { plans };
|
|
}
|
|
|
|
// Compute how many files would be updated by a rename/remove operation.
|
|
export async function previewCohortFrontmatterPropertyUpdates(
|
|
app: App,
|
|
files: TFile[],
|
|
valuesById: Map<string, number>,
|
|
newPropName: string,
|
|
oldPropName?: string,
|
|
idPropertyName?: string,
|
|
): Promise<{ wouldUpdate: number }> {
|
|
const { plans } = await planFrontmatterUpdates(
|
|
app,
|
|
files,
|
|
valuesById,
|
|
newPropName,
|
|
oldPropName,
|
|
idPropertyName,
|
|
);
|
|
return { wouldUpdate: plans.length };
|
|
}
|
|
|
|
// Generic bulk updater for frontmatter properties based on a values map.
|
|
// If oldPropName is provided, it will be removed if present (rename).
|
|
// If newPropName is empty and oldPropName is provided, performs removal only.
|
|
export async function updateCohortFrontmatterProperties(
|
|
app: App,
|
|
files: TFile[],
|
|
valuesById: Map<string, number>,
|
|
newPropName: string,
|
|
oldPropName?: string,
|
|
idPropertyName?: string,
|
|
): Promise<{ updated: number }> {
|
|
const { plans } = await planFrontmatterUpdates(
|
|
app,
|
|
files,
|
|
valuesById,
|
|
newPropName,
|
|
oldPropName,
|
|
idPropertyName,
|
|
);
|
|
|
|
let updated = 0;
|
|
for (const p of plans) {
|
|
await app.fileManager.processFrontMatter(p.file, (yamlRaw) => {
|
|
const yaml = yamlRaw as Record<string, unknown>;
|
|
if (p.op === 'set') {
|
|
yaml[p.setKey] = p.setValue;
|
|
if (p.deleteKey) {
|
|
delete yaml[p.deleteKey];
|
|
}
|
|
} else {
|
|
delete yaml[p.deleteKey];
|
|
}
|
|
});
|
|
updated += 1;
|
|
}
|
|
|
|
return { updated };
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper around updateCohortFrontmatterProperties that shows a working
|
|
* notice while the update runs. Allows custom notice text.
|
|
*/
|
|
export async function updateCohortFrontmatter(
|
|
app: App,
|
|
files: TFile[],
|
|
valuesById: Map<string, number>,
|
|
newPropName: string,
|
|
oldPropName?: string,
|
|
noticeMessage?: string,
|
|
idPropertyName?: string,
|
|
): Promise<{ updated: number }> {
|
|
return withNotice(noticeMessage ?? 'Updating frontmatter...', () =>
|
|
updateCohortFrontmatterProperties(
|
|
app,
|
|
files,
|
|
valuesById,
|
|
newPropName,
|
|
oldPropName,
|
|
idPropertyName,
|
|
),
|
|
);
|
|
}
|
|
|
|
// Refresh the rank and star properties across an entire cohort.
|
|
export async function refreshCohortRankAndStars(
|
|
app: App,
|
|
fm: FrontmatterPropertiesSettings,
|
|
cohort: CohortData,
|
|
files: TFile[],
|
|
idPropertyName?: string,
|
|
rankMap?: Map<string, number>,
|
|
): Promise<void> {
|
|
const wantRank = !!fm.rank.enabled && !!fm.rank.property;
|
|
const wantStars = !!fm.stars.enabled && !!fm.stars.property;
|
|
if ((!wantRank && !wantStars) || files.length === 0) return;
|
|
|
|
if (wantRank) {
|
|
await updateCohortFrontmatter(
|
|
app,
|
|
files,
|
|
rankMap ?? computeRanksForAll(cohort),
|
|
fm.rank.property,
|
|
undefined,
|
|
'Updating ranks...',
|
|
idPropertyName,
|
|
);
|
|
}
|
|
if (wantStars) {
|
|
await updateCohortFrontmatter(
|
|
app,
|
|
files,
|
|
computeStarsForAll(cohort, fm.stars),
|
|
fm.stars.property,
|
|
undefined,
|
|
'Updating star ratings...',
|
|
idPropertyName,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function writeFrontmatterStatsForPlayer(
|
|
app: App,
|
|
fm: FrontmatterPropertiesSettings,
|
|
cohort: CohortData,
|
|
file: TFile,
|
|
playerId: string,
|
|
precomputedRankMap: Map<string, number>,
|
|
starMap?: Map<string, number>,
|
|
): Promise<void> {
|
|
if (!anyEnabled(fm)) return;
|
|
const p = cohort.players[playerId];
|
|
if (!p) return;
|
|
const rankMap = precomputedRankMap;
|
|
const props = buildProps(fm, {
|
|
rating: p.rating,
|
|
uncertainty: p.sigma,
|
|
matches: p.matches,
|
|
wins: p.wins,
|
|
rank: rankMap.get(playerId) ?? rankMap.size,
|
|
stars: starMap?.get(playerId),
|
|
});
|
|
await writeProps(app, file, props);
|
|
}
|