/** * 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 '@core/shared/domain'; import { PenaltyId } from './penalty/PenaltyId'; import { LeagueId } from './LeagueId'; import { RaceId } from './RaceId'; import { DriverId } from './DriverId'; import { PenaltyType } from './penalty/PenaltyType'; import { PenaltyValue } from './penalty/PenaltyValue'; import { PenaltyReason } from './penalty/PenaltyReason'; import { ProtestId } from './ProtestId'; import { StewardId } from './StewardId'; import { PenaltyStatus } from './penalty/PenaltyStatus'; import { IssuedAt } from './IssuedAt'; import { AppliedAt } from './AppliedAt'; import { PenaltyNotes } from './penalty/PenaltyNotes'; export interface PenaltyProps { id: PenaltyId; leagueId: LeagueId; raceId: RaceId; /** The driver receiving the penalty */ driverId: DriverId; /** Type of penalty */ type: PenaltyType; /** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */ value?: PenaltyValue; /** Reason for the penalty */ reason: PenaltyReason; /** ID of the protest that led to this penalty (if applicable) */ protestId?: ProtestId; /** ID of the steward who issued the penalty */ issuedBy: StewardId; /** Current status of the penalty */ status: PenaltyStatus; /** Timestamp when the penalty was issued */ issuedAt: IssuedAt; /** Timestamp when the penalty was applied to results */ appliedAt?: AppliedAt; /** Notes about the penalty application */ notes?: PenaltyNotes; } export class Penalty implements IEntity { private constructor(private readonly props: PenaltyProps) {} static create(props: { id: string; leagueId: string; raceId: string; driverId: string; type: string; value?: number; reason: string; protestId?: string; issuedBy: string; status?: string; issuedAt?: Date; appliedAt?: Date; notes?: string; }): Penalty { if (!props.id) throw new RacingDomainValidationError('Penalty ID is required'); if (!props.leagueId) throw new RacingDomainValidationError('League 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`); } } const penaltyProps: PenaltyProps = { id: PenaltyId.create(props.id), leagueId: LeagueId.create(props.leagueId), raceId: RaceId.create(props.raceId), driverId: DriverId.create(props.driverId), type: PenaltyType.create(props.type), reason: PenaltyReason.create(props.reason), issuedBy: StewardId.create(props.issuedBy), status: PenaltyStatus.create(props.status || 'pending'), issuedAt: IssuedAt.create(props.issuedAt || new Date()), ...(props.value !== undefined && { value: PenaltyValue.create(props.value) }), ...(props.protestId !== undefined && { protestId: ProtestId.create(props.protestId) }), ...(props.appliedAt !== undefined && { appliedAt: AppliedAt.create(props.appliedAt) }), ...(props.notes !== undefined && { notes: PenaltyNotes.create(props.notes) }), }; return new Penalty(penaltyProps); } get id(): string { return this.props.id.toString(); } get leagueId(): string { return this.props.leagueId.toString(); } get raceId(): string { return this.props.raceId.toString(); } get driverId(): string { return this.props.driverId.toString(); } get type(): string { return this.props.type.toString(); } get value(): number | undefined { return this.props.value?.toNumber(); } get reason(): string { return this.props.reason.toString(); } get protestId(): string | undefined { return this.props.protestId?.toString(); } get issuedBy(): string { return this.props.issuedBy.toString(); } get status(): string { return this.props.status.toString(); } get issuedAt(): Date { return this.props.issuedAt.toDate(); } get appliedAt(): Date | undefined { return this.props.appliedAt?.toDate(); } get notes(): string | undefined { return this.props.notes?.toString(); } isPending(): boolean { return this.props.status.toString() === 'pending'; } isApplied(): boolean { return this.props.status.toString() === '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.toString() === 'overturned') { throw new RacingDomainInvariantError('Cannot apply an overturned penalty'); } const base: PenaltyProps = { ...this.props, status: PenaltyStatus.create('applied'), appliedAt: AppliedAt.create(new Date()), }; const next: PenaltyProps = notes !== undefined ? { ...base, notes: PenaltyNotes.create(notes) } : base; return new Penalty(next); } /** * Overturn the penalty (e.g., after successful appeal) */ overturn(reason: string): Penalty { if (this.props.status.toString() === 'overturned') { throw new RacingDomainInvariantError('Penalty is already overturned'); } return new Penalty({ ...this.props, status: PenaltyStatus.create('overturned'), notes: PenaltyNotes.create(reason), }); } /** * Get a human-readable description of the penalty */ getDescription(): string { switch (this.props.type.toString()) { case 'time_penalty': return `+${this.props.value?.toNumber()}s time penalty`; case 'grid_penalty': return `${this.props.value?.toNumber()} place grid penalty (next race)`; case 'points_deduction': return `${this.props.value?.toNumber()} championship points deducted`; case 'disqualification': return 'Disqualified from race'; case 'warning': return 'Official warning'; case 'license_points': return `${this.props.value?.toNumber()} license penalty points`; case 'probation': return 'Probationary period'; case 'fine': return `${this.props.value?.toNumber()} points fine`; case 'race_ban': return `${this.props.value?.toNumber()} race suspension`; default: return 'Penalty'; } } } // Export types for external use export { PenaltyType } from './penalty/PenaltyType'; export { PenaltyStatus } from './penalty/PenaltyStatus'; export { PenaltyValue } from './penalty/PenaltyValue'; export { PenaltyReason } from './penalty/PenaltyReason'; export { PenaltyNotes } from './penalty/PenaltyNotes'; export { PenaltyId } from './penalty/PenaltyId';