This commit is contained in:
2025-12-09 10:32:59 +01:00
parent 35f988f885
commit a780139692
26 changed files with 2224 additions and 344 deletions

View File

@@ -1,29 +1,142 @@
/**
* Domain Entity: Penalty
*
* Represents a season-long penalty or bonus applied to a driver
* within a specific league. This is intentionally simple for the
* alpha demo and models points adjustments only.
*
* 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.
*/
export type PenaltyType = 'points-deduction' | 'points-bonus';
export interface Penalty {
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 (future feature)
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
export interface PenaltyProps {
id: string;
leagueId: 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 {
private constructor(private readonly props: PenaltyProps) {}
static create(props: PenaltyProps): Penalty {
if (!props.id) throw new Error('Penalty ID is required');
if (!props.raceId) throw new Error('Race ID is required');
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.type) throw new Error('Penalty type is required');
if (!props.reason?.trim()) throw new Error('Penalty reason is required');
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction'].includes(props.type)) {
if (props.value === undefined || props.value <= 0) {
throw new Error(`${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';
}
/**
* Signed integer representing points adjustment:
* - negative for deductions
* - positive for bonuses
* Mark penalty as applied (after recalculating results)
*/
pointsDelta: number;
markAsApplied(notes?: string): Penalty {
if (this.isApplied()) {
throw new Error('Penalty is already applied');
}
if (this.props.status === 'overturned') {
throw new Error('Cannot apply an overturned penalty');
}
return new Penalty({
...this.props,
status: 'applied',
appliedAt: new Date(),
notes,
});
}
/**
* Optional short reason/label (e.g. "Incident penalty", "Fastest laps bonus").
* Overturn the penalty (e.g., after successful appeal)
*/
reason?: string;
overturn(reason: string): Penalty {
if (this.props.status === 'overturned') {
throw new Error('Penalty is already overturned');
}
return new Penalty({
...this.props,
status: 'overturned',
notes: reason,
});
}
/**
* When this penalty was applied.
* Get a human-readable description of the penalty
*/
appliedAt: Date;
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`;
default:
return 'Penalty';
}
}
}

View File

@@ -1,25 +1,54 @@
/**
* Application Port: IPenaltyRepository
*
* Repository interface for season-long penalties and bonuses applied
* to drivers within a league. This is intentionally simple for the
* alpha demo and operates purely on in-memory data.
* Repository Interface: IPenaltyRepository
*
* Defines the contract for persisting and retrieving Penalty entities.
*/
import type { Penalty } from '../entities/Penalty';
export interface IPenaltyRepository {
/**
* Get all penalties for a given league.
* Find a penalty by ID
*/
findByLeagueId(leagueId: string): Promise<Penalty[]>;
findById(id: string): Promise<Penalty | null>;
/**
* Get all penalties for a driver in a specific league.
* Find all penalties for a race
*/
findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]>;
findByRaceId(raceId: string): Promise<Penalty[]>;
/**
* Get all penalties in the system.
* Find all penalties for a specific driver
*/
findAll(): Promise<Penalty[]>;
findByDriverId(driverId: string): Promise<Penalty[]>;
/**
* Find all penalties related to a specific protest
*/
findByProtestId(protestId: string): Promise<Penalty[]>;
/**
* Find all pending penalties (not yet applied)
*/
findPending(): Promise<Penalty[]>;
/**
* Find all penalties issued by a specific steward
*/
findIssuedBy(stewardId: string): Promise<Penalty[]>;
/**
* Save a new penalty
*/
create(penalty: Penalty): Promise<void>;
/**
* Update an existing penalty
*/
update(penalty: Penalty): Promise<void>;
/**
* Check if a penalty exists
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,54 @@
/**
* Repository Interface: IProtestRepository
*
* Defines the contract for persisting and retrieving Protest entities.
*/
import type { Protest } from '../entities/Protest';
export interface IProtestRepository {
/**
* Find a protest by ID
*/
findById(id: string): Promise<Protest | null>;
/**
* Find all protests for a race
*/
findByRaceId(raceId: string): Promise<Protest[]>;
/**
* Find all protests filed by a specific driver
*/
findByProtestingDriverId(driverId: string): Promise<Protest[]>;
/**
* Find all protests against a specific driver
*/
findByAccusedDriverId(driverId: string): Promise<Protest[]>;
/**
* Find all pending protests (for steward review queue)
*/
findPending(): Promise<Protest[]>;
/**
* Find all protests under review by a specific steward
*/
findUnderReviewBy(stewardId: string): Promise<Protest[]>;
/**
* Save a new protest
*/
create(protest: Protest): Promise<void>;
/**
* Update an existing protest
*/
update(protest: Protest): Promise<void>;
/**
* Check if a protest exists
*/
exists(id: string): Promise<boolean>;
}

View File

@@ -120,8 +120,14 @@ export class EventScoringService {
private aggregatePenalties(penalties: Penalty[]): Map<string, number> {
const map = new Map<string, number>();
for (const penalty of penalties) {
// Only count applied points_deduction penalties
if (penalty.status !== 'applied' || penalty.type !== 'points_deduction') {
continue;
}
const current = map.get(penalty.driverId) ?? 0;
map.set(penalty.driverId, current + penalty.pointsDelta);
const delta = penalty.value ?? 0;
map.set(penalty.driverId, current + delta);
}
return map;
}