Files
gridpilot.gg/core/identity/domain/value-objects/UserRating.ts
2025-12-29 22:27:33 +01:00

287 lines
7.5 KiB
TypeScript

import type { IValueObject } from '@core/shared/domain';
/**
* Value Object: UserRating
*
* Multi-dimensional rating system for users covering:
* - Driver skill: racing ability, lap times, consistency
* - Admin competence: league management, event organization
* - Steward fairness: protest handling, penalty consistency
* - Trust score: reliability, sportsmanship, rule compliance
* - Fairness score: clean racing, incident involvement
*/
export interface RatingDimension {
value: number; // Current rating value (0-100 scale)
confidence: number; // Confidence level based on sample size (0-1)
sampleSize: number; // Number of events contributing to this rating
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
}
export interface UserRatingProps {
userId: string;
driver: RatingDimension;
admin: RatingDimension;
steward: RatingDimension;
trust: RatingDimension;
fairness: RatingDimension;
overallReputation: number;
calculatorVersion?: string;
createdAt: Date;
updatedAt: Date;
}
const DEFAULT_DIMENSION: RatingDimension = {
value: 50,
confidence: 0,
sampleSize: 0,
trend: 'stable',
lastUpdated: new Date(),
};
export class UserRating implements IValueObject<UserRatingProps> {
readonly props: UserRatingProps;
private constructor(props: UserRatingProps) {
this.props = props;
}
get userId(): string {
return this.props.userId;
}
get driver(): RatingDimension {
return this.props.driver;
}
get admin(): RatingDimension {
return this.props.admin;
}
get steward(): RatingDimension {
return this.props.steward;
}
get trust(): RatingDimension {
return this.props.trust;
}
get fairness(): RatingDimension {
return this.props.fairness;
}
get overallReputation(): number {
return this.props.overallReputation;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
get calculatorVersion(): string | undefined {
return this.props.calculatorVersion;
}
static create(userId: string): UserRating {
if (!userId || userId.trim().length === 0) {
throw new Error('UserRating userId is required');
}
const now = new Date();
return new UserRating({
userId,
driver: { ...DEFAULT_DIMENSION, lastUpdated: now },
admin: { ...DEFAULT_DIMENSION, lastUpdated: now },
steward: { ...DEFAULT_DIMENSION, lastUpdated: now },
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
overallReputation: 50,
calculatorVersion: '1.0',
createdAt: now,
updatedAt: now,
});
}
static restore(props: UserRatingProps): UserRating {
return new UserRating(props);
}
equals(other: IValueObject<UserRatingProps>): boolean {
return this.props.userId === other.props.userId;
}
/**
* Update driver rating based on race performance
*/
updateDriverRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.driver, newValue, weight);
return this.withUpdates({ driver: updated });
}
/**
* Update admin rating based on league management feedback
*/
updateAdminRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.admin, newValue, weight);
return this.withUpdates({ admin: updated });
}
/**
* Update steward rating based on protest handling feedback
*/
updateStewardRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.steward, newValue, weight);
return this.withUpdates({ steward: updated });
}
/**
* Update trust score based on reliability and sportsmanship
*/
updateTrustScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.trust, newValue, weight);
return this.withUpdates({ trust: updated });
}
/**
* Update fairness score based on clean racing incidents
*/
updateFairnessScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.fairness, newValue, weight);
return this.withUpdates({ fairness: updated });
}
/**
* Calculate weighted overall reputation
*/
calculateOverallReputation(): number {
// Weight dimensions by confidence and importance
const weights = {
driver: 0.25 * this.driver.confidence,
admin: 0.15 * this.admin.confidence,
steward: 0.15 * this.steward.confidence,
trust: 0.25 * this.trust.confidence,
fairness: 0.20 * this.fairness.confidence,
};
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
if (totalWeight === 0) {
return 50; // Default when no ratings yet
}
const weightedSum =
this.driver.value * weights.driver +
this.admin.value * weights.admin +
this.steward.value * weights.steward +
this.trust.value * weights.trust +
this.fairness.value * weights.fairness;
return Math.round(weightedSum / totalWeight);
}
/**
* Get rating tier for display
*/
getDriverTier(): 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite' {
if (this.driver.value >= 90) return 'elite';
if (this.driver.value >= 75) return 'pro';
if (this.driver.value >= 60) return 'semi-pro';
if (this.driver.value >= 40) return 'amateur';
return 'rookie';
}
/**
* Get trust level for matchmaking
*/
getTrustLevel(): 'unverified' | 'trusted' | 'highly-trusted' | 'community-leader' {
if (this.trust.value >= 90 && this.trust.sampleSize >= 50) return 'community-leader';
if (this.trust.value >= 75 && this.trust.sampleSize >= 20) return 'highly-trusted';
if (this.trust.value >= 60 && this.trust.sampleSize >= 5) return 'trusted';
return 'unverified';
}
/**
* Check if user is eligible to be a steward
*/
canBeSteward(): boolean {
return (
this.trust.value >= 70 &&
this.fairness.value >= 70 &&
this.trust.sampleSize >= 10
);
}
/**
* Check if user is eligible to be an admin
*/
canBeAdmin(): boolean {
return (
this.trust.value >= 60 &&
this.trust.sampleSize >= 5
);
}
private updateDimension(
dimension: RatingDimension,
newValue: number,
weight: number
): RatingDimension {
const clampedValue = Math.max(0, Math.min(100, newValue));
const newSampleSize = dimension.sampleSize + weight;
// Exponential moving average with decay based on sample size
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
// Calculate confidence (asymptotic to 1)
const confidence = 1 - Math.exp(-newSampleSize / 20);
// Determine trend
const valueDiff = updatedValue - dimension.value;
let trend: 'rising' | 'stable' | 'falling' = 'stable';
if (valueDiff > 2) trend = 'rising';
if (valueDiff < -2) trend = 'falling';
return {
value: Math.round(updatedValue * 10) / 10,
confidence: Math.round(confidence * 100) / 100,
sampleSize: newSampleSize,
trend,
lastUpdated: new Date(),
};
}
private withUpdates(updates: Partial<UserRatingProps>): UserRating {
const newRating = new UserRating({
...this.props,
...updates,
updatedAt: new Date(),
});
// Recalculate overall reputation
return new UserRating({
...newRating.props,
overallReputation: newRating.calculateOverallReputation(),
});
}
}