/** * Domain Entity: Penalty * * Represents a penalty applied to a driver for an incident during a race. * Penalties can be applied as a result of an upheld protest or directly by stewards. */ import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@gridpilot/shared/domain'; export type PenaltyType = | 'time_penalty' // Add time to race result (e.g., +5 seconds) | 'grid_penalty' // Grid position penalty for next race | 'points_deduction' // Deduct championship points | 'disqualification' // DSQ from the race | 'warning' // Official warning (no immediate consequence) | 'license_points' // Add penalty points to license (safety rating) | 'probation' // Conditional penalty | 'fine' // Monetary/points fine | 'race_ban'; // Multi-race suspension export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned'; export interface PenaltyProps { id: string; raceId: string; /** The driver receiving the penalty */ driverId: string; /** Type of penalty */ type: PenaltyType; /** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */ value?: number; /** Reason for the penalty */ reason: string; /** ID of the protest that led to this penalty (if applicable) */ protestId?: string; /** ID of the steward who issued the penalty */ issuedBy: string; /** Current status of the penalty */ status: PenaltyStatus; /** Timestamp when the penalty was issued */ issuedAt: Date; /** Timestamp when the penalty was applied to results */ appliedAt?: Date; /** Notes about the penalty application */ notes?: string; } export class Penalty implements IEntity { private constructor(private readonly props: PenaltyProps) {} static create(props: PenaltyProps): Penalty { if (!props.id) throw new RacingDomainValidationError('Penalty ID is required'); if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required'); if (!props.type) throw new RacingDomainValidationError('Penalty type is required'); if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); // Validate value based on type if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) { if (props.value === undefined || props.value <= 0) { throw new RacingDomainValidationError(`${props.type} requires a positive value`); } } return new Penalty({ ...props, status: props.status || 'pending', issuedAt: props.issuedAt || new Date(), }); } get id(): string { return this.props.id; } get raceId(): string { return this.props.raceId; } get driverId(): string { return this.props.driverId; } get type(): PenaltyType { return this.props.type; } get value(): number | undefined { return this.props.value; } get reason(): string { return this.props.reason; } get protestId(): string | undefined { return this.props.protestId; } get issuedBy(): string { return this.props.issuedBy; } get status(): PenaltyStatus { return this.props.status; } get issuedAt(): Date { return this.props.issuedAt; } get appliedAt(): Date | undefined { return this.props.appliedAt; } get notes(): string | undefined { return this.props.notes; } isPending(): boolean { return this.props.status === 'pending'; } isApplied(): boolean { return this.props.status === 'applied'; } /** * Mark penalty as applied (after recalculating results) */ markAsApplied(notes?: string): Penalty { if (this.isApplied()) { throw new RacingDomainInvariantError('Penalty is already applied'); } if (this.props.status === 'overturned') { throw new RacingDomainInvariantError('Cannot apply an overturned penalty'); } const base: PenaltyProps = { ...this.props, status: 'applied', appliedAt: new Date(), }; const next: PenaltyProps = notes !== undefined ? { ...base, notes } : base; return Penalty.create(next); } /** * Overturn the penalty (e.g., after successful appeal) */ overturn(reason: string): Penalty { if (this.props.status === 'overturned') { throw new RacingDomainInvariantError('Penalty is already overturned'); } return new Penalty({ ...this.props, status: 'overturned', notes: reason, }); } /** * Get a human-readable description of the penalty */ getDescription(): string { switch (this.props.type) { case 'time_penalty': return `+${this.props.value}s time penalty`; case 'grid_penalty': return `${this.props.value} place grid penalty (next race)`; case 'points_deduction': return `${this.props.value} championship points deducted`; case 'disqualification': return 'Disqualified from race'; case 'warning': return 'Official warning'; case 'license_points': return `${this.props.value} license penalty points`; case 'probation': return 'Probationary period'; case 'fine': return `${this.props.value} points fine`; case 'race_ban': return `${this.props.value} race suspension`; default: return 'Penalty'; } } }