rating
This commit is contained in:
655
core/identity/domain/services/RatingEventFactory.ts
Normal file
655
core/identity/domain/services/RatingEventFactory.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
|
||||
import { AdminTrustReasonCode } from '../value-objects/AdminTrustReasonCode';
|
||||
|
||||
// Existing interfaces
|
||||
interface RaceFinishInput {
|
||||
userId: string;
|
||||
raceId: string;
|
||||
position: number;
|
||||
totalDrivers: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
fieldStrength: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
}
|
||||
|
||||
interface PenaltyInput {
|
||||
userId: string;
|
||||
penaltyId: string;
|
||||
penaltyType: 'incident' | 'admin_violation';
|
||||
severity: 'minor' | 'major';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface VoteInput {
|
||||
userId: string;
|
||||
voteSessionId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}
|
||||
|
||||
interface AdminActionInput {
|
||||
userId: string;
|
||||
adminActionId: string;
|
||||
actionType: 'sla_response' | 'abuse_report' | 'rule_clarity';
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// NEW: Enhanced interface for race facts (per plans section 5.1.2)
|
||||
export interface RaceFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number; // Optional strength of field
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: RatingEventFactory
|
||||
*
|
||||
* Pure, stateless factory that turns domain facts into rating events.
|
||||
* Follows the pattern of creating immutable entities from business facts.
|
||||
* Enhanced to support full driving event taxonomy from plans.
|
||||
*/
|
||||
export class RatingEventFactory {
|
||||
/**
|
||||
* Create rating events from race finish data
|
||||
* Handles performance, clean driving, and reliability dimensions
|
||||
*/
|
||||
static createFromRaceFinish(input: RaceFinishInput): RatingEvent[] {
|
||||
const events: RatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Performance events (only for finished races)
|
||||
if (input.status === 'finished') {
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
input.position,
|
||||
input.totalDrivers,
|
||||
input.startPosition,
|
||||
input.fieldStrength
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
summary: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in field of ${input.totalDrivers}`,
|
||||
details: {
|
||||
position: input.position,
|
||||
totalDrivers: input.totalDrivers,
|
||||
startPosition: input.startPosition,
|
||||
fieldStrength: input.fieldStrength,
|
||||
positionsGained: input.startPosition - input.position,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Positions gained bonus
|
||||
const positionsGained = input.startPosition - input.position;
|
||||
if (positionsGained > 0) {
|
||||
const gainBonus = Math.min(positionsGained * 2, 10); // Max 10 points
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_POSITIONS_GAINED_BONUS',
|
||||
summary: `Gained ${positionsGained} positions`,
|
||||
details: { positionsGained },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean driving penalty (incidents)
|
||||
if (input.incidents > 0) {
|
||||
const incidentPenalty = Math.min(input.incidents * 5, 30); // Max 30 points penalty
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_INCIDENTS_PENALTY',
|
||||
summary: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
|
||||
details: { incidents: input.incidents },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Reliability penalties
|
||||
if (input.status === 'dns') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DNS_PENALTY',
|
||||
summary: 'Did not start',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dnf') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DNF_PENALTY',
|
||||
summary: 'Did not finish',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dsq') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DSQ_PENALTY',
|
||||
summary: 'Disqualified',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'afk') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_AFK_PENALTY',
|
||||
summary: 'AFK / Not responsive',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: Create rating events from race facts DTO
|
||||
* Supports multiple drivers and full event taxonomy
|
||||
* Returns a map of userId to events for efficient processing
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: RaceFactsDto): Map<string, RatingEvent[]> {
|
||||
const eventsByUser = new Map<string, RatingEvent[]>();
|
||||
const now = new Date();
|
||||
|
||||
// Calculate field strength if not provided in all results
|
||||
const hasSof = raceFacts.results.some(r => r.sof !== undefined);
|
||||
let fieldStrength = 0;
|
||||
|
||||
if (hasSof) {
|
||||
const sofResults = raceFacts.results.filter(r => r.sof !== undefined);
|
||||
fieldStrength = sofResults.reduce((sum, r) => sum + r.sof!, 0) / sofResults.length;
|
||||
} else {
|
||||
// Use average of finished positions as proxy
|
||||
const finishedResults = raceFacts.results.filter(r => r.status === 'finished');
|
||||
if (finishedResults.length > 0) {
|
||||
fieldStrength = finishedResults.reduce((sum, r) => sum + (r.finishPos * 100), 0) / finishedResults.length;
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
// 1. Performance events (only for finished races)
|
||||
if (result.status === 'finished') {
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
result.finishPos,
|
||||
raceFacts.results.length,
|
||||
result.startPos,
|
||||
fieldStrength
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN').value,
|
||||
summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${raceFacts.results.length}`,
|
||||
details: {
|
||||
startPos: result.startPos,
|
||||
finishPos: result.finishPos,
|
||||
positionsGained: result.startPos - result.finishPos,
|
||||
fieldStrength: fieldStrength,
|
||||
totalDrivers: raceFacts.results.length,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Positions gained bonus
|
||||
const positionsGained = result.startPos - result.finishPos;
|
||||
if (positionsGained > 0) {
|
||||
const gainBonus = Math.min(positionsGained * 2, 10);
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_POSITIONS_GAINED_BONUS').value,
|
||||
summary: `Gained ${positionsGained} positions`,
|
||||
details: { positionsGained },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean driving penalty (incidents)
|
||||
if (result.incidents > 0) {
|
||||
const incidentPenalty = Math.min(result.incidents * 5, 30);
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY').value,
|
||||
summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
|
||||
details: { incidents: result.incidents },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reliability penalties
|
||||
if (result.status !== 'finished') {
|
||||
let penaltyDelta = 0;
|
||||
let reasonCode: DrivingReasonCodeValue;
|
||||
|
||||
switch (result.status) {
|
||||
case 'dns':
|
||||
penaltyDelta = -15;
|
||||
reasonCode = 'DRIVING_DNS_PENALTY';
|
||||
break;
|
||||
case 'dnf':
|
||||
penaltyDelta = -10;
|
||||
reasonCode = 'DRIVING_DNF_PENALTY';
|
||||
break;
|
||||
case 'dsq':
|
||||
penaltyDelta = -25;
|
||||
reasonCode = 'DRIVING_DSQ_PENALTY';
|
||||
break;
|
||||
case 'afk':
|
||||
penaltyDelta = -20;
|
||||
reasonCode = 'DRIVING_AFK_PENALTY';
|
||||
break;
|
||||
default:
|
||||
continue; // Skip unknown statuses
|
||||
}
|
||||
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(penaltyDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create(reasonCode).value,
|
||||
summary: this.getStatusSummary(result.status),
|
||||
details: { status: result.status },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByUser.set(result.userId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from penalty data
|
||||
*/
|
||||
static createFromPenalty(input: PenaltyInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
if (input.penaltyType === 'incident') {
|
||||
const delta = input.severity === 'major' ? -15 : -5;
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_PENALTY_INVOLVEMENT_PENALTY').value,
|
||||
summary: input.reason,
|
||||
details: { severity: input.severity, type: input.penaltyType },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.penaltyType === 'admin_violation') {
|
||||
const delta = input.severity === 'major' ? -20 : -10;
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyId },
|
||||
reason: {
|
||||
code: 'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||
summary: input.reason,
|
||||
details: { severity: input.severity, type: input.penaltyType },
|
||||
},
|
||||
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from vote outcome
|
||||
*/
|
||||
static createFromVote(input: VoteInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
// Scale: -20 to +20 based on percentage
|
||||
let delta: number;
|
||||
if (input.outcome === 'positive') {
|
||||
delta = Math.round((input.percentPositive / 100) * 20); // 0 to +20
|
||||
} else {
|
||||
delta = -Math.round(((100 - input.percentPositive) / 100) * 20); // -20 to 0
|
||||
}
|
||||
|
||||
if (delta !== 0) {
|
||||
const reasonCode = input.outcome === 'positive'
|
||||
? AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE').value
|
||||
: AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE').value;
|
||||
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: input.voteSessionId },
|
||||
reason: {
|
||||
code: reasonCode,
|
||||
summary: `Vote outcome: ${input.percentPositive}% positive (${input.voteCount}/${input.eligibleVoterCount})`,
|
||||
details: {
|
||||
voteCount: input.voteCount,
|
||||
eligibleVoterCount: input.eligibleVoterCount,
|
||||
percentPositive: input.percentPositive,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from admin action
|
||||
*/
|
||||
static createFromAdminAction(input: AdminActionInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'sla_response') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_SLA_BONUS').value,
|
||||
summary: 'Timely response to admin task',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'abuse_report') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_ABUSE_REPORT_PENALTY').value,
|
||||
summary: 'Validated abuse report',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'rule_clarity') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_RULE_CLARITY_BONUS').value,
|
||||
summary: 'Published clear rules/changes',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance delta based on position and field strength
|
||||
*/
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
totalDrivers: number,
|
||||
startPosition: number,
|
||||
fieldStrength: number
|
||||
): number {
|
||||
// Base score from position (reverse percentile)
|
||||
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||
|
||||
// Bonus for positions gained
|
||||
const positionsGained = startPosition - position;
|
||||
const gainBonus = Math.max(0, positionsGained * 2);
|
||||
|
||||
// Field strength multiplier (higher field strength = harder competition)
|
||||
// Normalize field strength to 0.8-1.2 range
|
||||
const fieldMultiplier = 0.8 + Math.min(fieldStrength / 10000, 0.4);
|
||||
|
||||
const rawScore = (positionScore + gainBonus) * fieldMultiplier;
|
||||
|
||||
// Convert to delta (range -50 to +50)
|
||||
// 50th percentile = 0, top = +50, bottom = -50
|
||||
return Math.round(rawScore - 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ordinal suffix for position
|
||||
*/
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable summary for status
|
||||
*/
|
||||
private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string {
|
||||
switch (status) {
|
||||
case 'finished': return 'Race completed';
|
||||
case 'dnf': return 'Did not finish';
|
||||
case 'dns': return 'Did not start';
|
||||
case 'dsq': return 'Disqualified';
|
||||
case 'afk': return 'AFK / Not responsive';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type export for convenience
|
||||
export type DrivingReasonCodeValue =
|
||||
| 'DRIVING_FINISH_STRENGTH_GAIN'
|
||||
| 'DRIVING_POSITIONS_GAINED_BONUS'
|
||||
| 'DRIVING_PACE_RELATIVE_GAIN'
|
||||
| 'DRIVING_INCIDENTS_PENALTY'
|
||||
| 'DRIVING_MAJOR_CONTACT_PENALTY'
|
||||
| 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'
|
||||
| 'DRIVING_DNS_PENALTY'
|
||||
| 'DRIVING_DNF_PENALTY'
|
||||
| 'DRIVING_DSQ_PENALTY'
|
||||
| 'DRIVING_AFK_PENALTY'
|
||||
| 'DRIVING_SEASON_ATTENDANCE_BONUS';
|
||||
Reference in New Issue
Block a user