287 lines
7.5 KiB
TypeScript
287 lines
7.5 KiB
TypeScript
import type { ValueObject } from '@core/shared/domain/ValueObject';
|
|
|
|
/**
|
|
* 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 ValueObject<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: ValueObject<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(),
|
|
});
|
|
}
|
|
} |