655 lines
21 KiB
TypeScript
655 lines
21 KiB
TypeScript
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'; |