158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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';
|
|
}
|
|
}
|
|
} |