rspace-online/modules/rtime/reputation.ts

106 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Per-skill reputation scoring with time-based decay.
*
* Score formula: baseScore × decayFactor
* baseScore = (avgRating / 5) × 80 + (min(completedHours, 50) / 50) × 20
* decayFactor = e^(-λ × daysSinceLastRating) where λ = 0.005
*
* Score range: 0-100. New members start at 50 (neutral).
*/
import type { Skill } from './schemas';
import type { ReputationDoc, ReputationEntry, ReputationRating } from './schemas-intent';
// ── Constants ──
/** Default score for members with no reputation */
export const DEFAULT_REPUTATION = 50;
/** Decay rate (per day). At λ=0.005, score halves after ~139 days of inactivity */
const DECAY_LAMBDA = 0.005;
/** Rating weight (out of 100) */
const RATING_WEIGHT = 80;
/** Hours weight (out of 100) */
const HOURS_WEIGHT = 20;
/** Hours cap for scoring (diminishing returns beyond this) */
const HOURS_CAP = 50;
const MS_PER_DAY = 86_400_000;
// ── Scoring ──
/**
* Calculate the reputation score for a member-skill pair.
*/
export function calculateScore(entry: ReputationEntry, now: number = Date.now()): number {
if (entry.ratings.length === 0) return DEFAULT_REPUTATION;
// Average rating (1-5 scale)
const avgRating = entry.ratings.reduce((sum, r) => sum + r.score, 0) / entry.ratings.length;
// Base score: rating component + hours component
const ratingComponent = (avgRating / 5) * RATING_WEIGHT;
const hoursComponent = (Math.min(entry.completedHours, HOURS_CAP) / HOURS_CAP) * HOURS_WEIGHT;
const baseScore = ratingComponent + hoursComponent;
// Decay based on time since most recent rating
const lastRating = entry.ratings.reduce((latest, r) => Math.max(latest, r.timestamp), 0);
const daysSince = (now - lastRating) / MS_PER_DAY;
const decayFactor = Math.exp(-DECAY_LAMBDA * Math.max(0, daysSince));
return Math.round(baseScore * decayFactor);
}
/**
* Get a member's reputation for a specific skill, or DEFAULT_REPUTATION if none.
*/
export function getMemberSkillReputation(
memberId: string,
skill: Skill,
reputationDoc: ReputationDoc,
now?: number,
): number {
const key = `${memberId}:${skill}`;
const entry = reputationDoc.entries[key];
if (!entry) return DEFAULT_REPUTATION;
return calculateScore(entry, now);
}
/**
* Get a member's average reputation across all skills they have entries for.
*/
export function getMemberOverallReputation(
memberId: string,
reputationDoc: ReputationDoc,
now?: number,
): number {
const entries = Object.values(reputationDoc.entries)
.filter(e => e.memberId === memberId);
if (entries.length === 0) return DEFAULT_REPUTATION;
const total = entries.reduce((sum, e) => sum + calculateScore(e, now), 0);
return Math.round(total / entries.length);
}
/**
* Build a reputation entry key from memberId and skill.
*/
export function reputationKey(memberId: string, skill: Skill): string {
return `${memberId}:${skill}`;
}
/**
* Create a new rating to add to a reputation entry.
*/
export function createRating(fromMemberId: string, score: number): ReputationRating {
return {
from: fromMemberId,
score: Math.max(1, Math.min(5, Math.round(score))),
timestamp: Date.now(),
};
}