This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -0,0 +1,86 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@gridpilot/shared/domain';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
/**
* Use Case: CloseRaceEventStewardingUseCase
*
* Scheduled job that checks for race events with expired stewarding windows
* and closes them, triggering final results notifications.
*
* This would typically be run by a scheduled job (e.g., every 5 minutes)
* to automatically close stewarding windows based on league configuration.
*/
export interface CloseRaceEventStewardingCommand {
// No parameters needed - finds all expired events automatically
}
export class CloseRaceEventStewardingUseCase
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
{
constructor(
private readonly raceEventRepository: IRaceEventRepository,
private readonly domainEventPublisher: IDomainEventPublisher,
) {}
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
for (const raceEvent of expiredEvents) {
await this.closeStewardingForRaceEvent(raceEvent);
}
}
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> {
try {
// Close the stewarding window
const closedRaceEvent = raceEvent.closeStewarding();
await this.raceEventRepository.update(closedRaceEvent);
// Get list of participating drivers (would need to be implemented)
const driverIds = await this.getParticipatingDriverIds(raceEvent);
// Check if any penalties were applied during stewarding
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
// Publish domain event to trigger final results notifications
const event = new RaceEventStewardingClosedEvent({
raceEventId: raceEvent.id,
leagueId: raceEvent.leagueId,
seasonId: raceEvent.seasonId,
closedAt: new Date(),
driverIds,
hadPenaltiesApplied,
});
await this.domainEventPublisher.publish(event);
} catch (error) {
console.error(`Failed to close stewarding for race event ${raceEvent.id}:`, error);
// In production, this would trigger alerts/monitoring
}
}
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> {
// In a real implementation, this would query race registrations
// For the prototype, we'll return a mock list
// This would typically involve:
// 1. Get all sessions in the race event
// 2. For each session, get registered drivers
// 3. Return unique driver IDs across all sessions
// Mock implementation for prototype
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
}
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
// In a real implementation, this would check if any penalties were issued
// during the stewarding window for this race event
// This would query the penalty repository for penalties related to this race event
// Mock implementation for prototype - randomly simulate penalties
return Math.random() > 0.7; // 30% chance of penalties being applied
}
}

View File

@@ -0,0 +1,160 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Use Case: CompleteRaceUseCase
*
* Encapsulates the workflow for completing a race:
* - loads the race by id
* - throws if the race does not exist
* - delegates completion rules to the Race domain entity
* - automatically generates realistic results for registered drivers
* - updates league standings
* - persists all changes via repositories.
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
throw new Error('Cannot complete race with no registered drivers');
}
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
// Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Update standings
await this.updateStandings(race.leagueId, results);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
}
private generateRaceResults(
raceId: string,
driverIds: string[],
driverRatings: Map<string, number>
): Result[] {
// Create driver performance data
const driverPerformances = driverIds.map(driverId => ({
driverId,
rating: driverRatings.get(driverId) ?? 1500, // Default rating
randomFactor: Math.random() - 0.5, // -0.5 to +0.5 randomization
}));
// Sort by performance (rating + randomization)
driverPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 200); // ±100 rating points randomization
const perfB = b.rating + (b.randomFactor * 200);
return perfB - perfA; // Higher performance first
});
// Generate qualifying results for start positions (similar but different from race results)
const qualiPerformances = driverPerformances.map(p => ({
...p,
randomFactor: Math.random() - 0.5, // New randomization for quali
}));
qualiPerformances.sort((a, b) => {
const perfA = a.rating + (a.randomFactor * 150);
const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA;
});
// Generate results
const results: Result[] = [];
for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i];
const position = i + 1;
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
// Generate realistic lap times (90-120 seconds for a lap)
const baseLapTime = 90000 + Math.random() * 30000;
const positionBonus = (position - 1) * 500; // Winners are faster
const fastestLap = Math.round(baseLapTime + positionBonus + Math.random() * 5000);
// Generate incidents (0-3, higher for lower positions)
const incidentProbability = Math.min(0.8, position / driverPerformances.length);
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
results.push(
Result.create({
id: `${raceId}-${driverId}`,
raceId,
driverId,
position,
startPosition,
fastestLap,
incidents,
})
);
}
return results;
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
});
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
await this.standingRepository.save(standing);
}
}
}

View File

@@ -0,0 +1,108 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
import { RatingUpdateService } from '@gridpilot/identity/domain/services/RatingUpdateService';
import type { AsyncUseCase } from '@gridpilot/shared/application';
/**
* Enhanced CompleteRaceUseCase that includes rating updates
*/
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
throw new Error('Race not found');
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
throw new Error('Cannot complete race with no registered drivers');
}
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
// Generate realistic race results
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Update standings
await this.updateStandings(race.leagueId, results);
// Update driver ratings based on performance
await this.updateDriverRatings(results, registeredDriverIds.length);
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
if (!standing) {
standing = Standing.create({
leagueId,
driverId,
});
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
});
}
await this.standingRepository.save(standing);
}
}
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
const driverResults = results.map(result => ({
driverId: result.driverId,
position: result.position,
totalDrivers,
incidents: result.incidents,
startPosition: result.startPosition,
}));
await this.ratingUpdateService.updateDriverRatingsAfterRace(driverResults);
}
}

View File

@@ -0,0 +1,138 @@
/**
* Use Case: QuickPenaltyUseCase
*
* Allows league admins to quickly issue common penalties without protest process.
* Designed for fast, common penalty scenarios like track limits, warnings, etc.
*/
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@gridpilot/shared/application';
export interface QuickPenaltyCommand {
raceId: string;
driverId: string;
adminId: string;
infractionType: 'track_limits' | 'unsafe_rejoin' | 'aggressive_driving' | 'false_start' | 'other';
severity: 'warning' | 'minor' | 'major' | 'severe';
notes?: string;
}
export class QuickPenaltyUseCase
implements AsyncUseCase<QuickPenaltyCommand, { penaltyId: string }> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
throw new Error('Race not found');
}
// Validate admin has authority
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const adminMembership = memberships.find(
m => m.driverId === command.adminId && m.status === 'active'
);
if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) {
throw new Error('Only league owners and admins can issue penalties');
}
// Map infraction + severity to penalty type and value
const { type, value, reason } = this.mapInfractionToPenalty(
command.infractionType,
command.severity
);
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type,
...(value !== undefined ? { value } : {}),
reason,
issuedBy: command.adminId,
status: 'applied', // Quick penalties are applied immediately
issuedAt: new Date(),
appliedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
return { penaltyId: penalty.id };
}
private mapInfractionToPenalty(
infractionType: QuickPenaltyCommand['infractionType'],
severity: QuickPenaltyCommand['severity']
): { type: PenaltyType; value?: number; reason: string } {
const severityMultipliers = {
warning: 1,
minor: 2,
major: 3,
severe: 4,
};
const multiplier = severityMultipliers[severity];
switch (infractionType) {
case 'track_limits':
if (severity === 'warning') {
return { type: 'warning', reason: 'Track limits violation - warning' };
}
return {
type: 'points_deduction',
value: multiplier,
reason: `Track limits violation - ${multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
case 'unsafe_rejoin':
return {
type: 'time_penalty',
value: 5 * multiplier,
reason: `Unsafe rejoining to track - +${5 * multiplier}s time penalty`
};
case 'aggressive_driving':
if (severity === 'warning') {
return { type: 'warning', reason: 'Aggressive driving - warning' };
}
return {
type: 'points_deduction',
value: 2 * multiplier,
reason: `Aggressive driving - ${2 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
case 'false_start':
return {
type: 'grid_penalty',
value: multiplier,
reason: `False start - ${multiplier} grid position${multiplier > 1 ? 's' : ''} penalty`
};
case 'other':
if (severity === 'warning') {
return { type: 'warning', reason: 'General infraction - warning' };
}
return {
type: 'points_deduction',
value: 3 * multiplier,
reason: `General infraction - ${3 * multiplier} point${multiplier > 1 ? 's' : ''} deducted`
};
default:
throw new Error(`Unknown infraction type: ${infractionType}`);
}
}
}

View File

@@ -0,0 +1,158 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
/**
* Use Case: SendFinalResultsUseCase
*
* Triggered by RaceEventStewardingClosed domain event.
* Sends final results modal notifications to all drivers who participated,
* including any penalty adjustments applied during stewarding.
*/
export class SendFinalResultsUseCase implements UseCase<RaceEventStewardingClosedEvent, void, void, void> {
constructor(
private readonly notificationService: INotificationService,
private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository,
) {}
async execute(event: RaceEventStewardingClosedEvent): Promise<void> {
const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData;
// Get race event to include context
const raceEvent = await this.raceEventRepository.findById(raceEventId);
if (!raceEvent) {
console.warn(`RaceEvent ${raceEventId} not found, skipping final results notifications`);
return;
}
// Get final results for the main race session
const mainRaceSession = raceEvent.getMainRaceSession();
if (!mainRaceSession) {
console.warn(`No main race session found for RaceEvent ${raceEventId}`);
return;
}
const results = await this.resultRepository.findByRaceId(mainRaceSession.id);
// Send final results to each participating driver
for (const driverId of driverIds) {
const driverResult = results.find(r => r.driverId === driverId);
await this.sendFinalResultsNotification(
driverId,
raceEvent,
driverResult,
leagueId,
hadPenaltiesApplied
);
}
}
private async sendFinalResultsNotification(
driverId: string,
raceEvent: any, // RaceEvent type
driverResult: any, // Result type
leagueId: string,
hadPenaltiesApplied: boolean
): Promise<void> {
const position = driverResult?.position ?? 'DNF';
const positionChange = driverResult?.getPositionChange() ?? 0;
const incidents = driverResult?.incidents ?? 0;
// Calculate final rating change (could include penalty adjustments)
const finalRatingChange = this.calculateFinalRatingChange(
driverResult?.position,
driverResult?.incidents,
hadPenaltiesApplied
);
const title = `Final Results: ${raceEvent.name}`;
const body = this.buildFinalResultsBody(
position,
positionChange,
incidents,
finalRatingChange,
hadPenaltiesApplied
);
await this.notificationService.sendNotification({
recipientId: driverId,
type: 'race_final_results' as NotificationType,
title,
body,
channel: 'in_app',
urgency: 'modal',
data: {
raceEventId: raceEvent.id,
sessionId: raceEvent.getMainRaceSession()?.id,
leagueId,
position,
positionChange,
incidents,
finalRatingChange,
hadPenaltiesApplied,
},
actions: [
{
label: 'View Championship Standings',
type: 'primary',
href: `/leagues/${leagueId}/standings`,
},
{
label: 'Race Details',
type: 'secondary',
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
},
],
requiresResponse: false, // Can be dismissed, shows final results
});
}
private buildFinalResultsBody(
position: number | 'DNF',
positionChange: number,
incidents: number,
finalRatingChange: number,
hadPenaltiesApplied: boolean
): string {
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
positionChange < 0 ? `${positionChange}` : '±0';
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
const ratingText = finalRatingChange >= 0 ?
`+${finalRatingChange} rating` :
`${finalRatingChange} rating`;
const penaltyText = hadPenaltiesApplied ?
' (including stewarding adjustments)' : '';
return `Final result: ${positionText} (${positionChangeText} positions). ${incidentsText} ${ratingText}${penaltyText}.`;
}
private calculateFinalRatingChange(
position?: number,
incidents?: number,
hadPenaltiesApplied?: boolean
): number {
if (!position) return -10; // DNF penalty
// Base calculation (same as provisional)
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
const incidentPenalty = (incidents ?? 0) * -5;
let finalChange = baseChange + positionBonus + incidentPenalty;
// Additional penalty adjustments if stewarding applied penalties
if (hadPenaltiesApplied) {
// In a real implementation, this would check actual penalties applied
// For now, we'll assume some penalties might have been applied
finalChange = Math.max(finalChange - 5, -20); // Cap penalty at -20
}
return finalChange;
}
}

View File

@@ -0,0 +1,125 @@
import type { UseCase } from '@gridpilot/shared/application/UseCase';
import type { INotificationService } from '../../../notifications/application/ports/INotificationService';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes';
/**
* Use Case: SendPerformanceSummaryUseCase
*
* Triggered by MainRaceCompleted domain event.
* Sends immediate performance summary modal notifications to all drivers who participated in the main race.
*/
export class SendPerformanceSummaryUseCase implements UseCase<MainRaceCompletedEvent, void, void, void> {
constructor(
private readonly notificationService: INotificationService,
private readonly raceEventRepository: IRaceEventRepository,
private readonly resultRepository: IResultRepository,
) {}
async execute(event: MainRaceCompletedEvent): Promise<void> {
const { raceEventId, sessionId, leagueId, driverIds } = event.eventData;
// Get race event to include context
const raceEvent = await this.raceEventRepository.findById(raceEventId);
if (!raceEvent) {
console.warn(`RaceEvent ${raceEventId} not found, skipping performance summary notifications`);
return;
}
// Get results for the main race session to calculate performance data
const results = await this.resultRepository.findByRaceId(sessionId);
// Send performance summary to each participating driver
for (const driverId of driverIds) {
const driverResult = results.find(r => r.driverId === driverId);
await this.sendPerformanceSummaryNotification(
driverId,
raceEvent,
driverResult,
leagueId
);
}
}
private async sendPerformanceSummaryNotification(
driverId: string,
raceEvent: any, // RaceEvent type
driverResult: any, // Result type
leagueId: string
): Promise<void> {
const position = driverResult?.position ?? 'DNF';
const positionChange = driverResult?.getPositionChange() ?? 0;
const incidents = driverResult?.incidents ?? 0;
// Calculate provisional rating change (simplified version)
const provisionalRatingChange = this.calculateProvisionalRatingChange(
driverResult?.position,
driverResult?.incidents
);
const title = `Race Complete: ${raceEvent.name}`;
const body = this.buildPerformanceSummaryBody(
position,
positionChange,
incidents,
provisionalRatingChange
);
await this.notificationService.sendNotification({
recipientId: driverId,
type: 'race_performance_summary' as NotificationType,
title,
body,
channel: 'in_app',
urgency: 'modal',
data: {
raceEventId: raceEvent.id,
sessionId: raceEvent.getMainRaceSession()?.id,
leagueId,
position,
positionChange,
incidents,
provisionalRatingChange,
},
actions: [
{
label: 'View Full Results',
type: 'primary',
href: `/leagues/${leagueId}/races/${raceEvent.id}`,
},
],
requiresResponse: false, // Can be dismissed, but shows performance data
});
}
private buildPerformanceSummaryBody(
position: number | 'DNF',
positionChange: number,
incidents: number,
provisionalRatingChange: number
): string {
const positionText = position === 'DNF' ? 'DNF' : `P${position}`;
const positionChangeText = positionChange > 0 ? `+${positionChange}` :
positionChange < 0 ? `${positionChange}` : '±0';
const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`;
const ratingText = provisionalRatingChange >= 0 ?
`+${provisionalRatingChange} rating` :
`${provisionalRatingChange} rating`;
return `You finished ${positionText} (${positionChangeText} positions). ${incidentsText} Provisional ${ratingText}.`;
}
private calculateProvisionalRatingChange(position?: number, incidents?: number): number {
if (!position) return -10; // DNF penalty
// Simplified rating calculation (matches existing GetRaceDetailUseCase logic)
const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5;
const positionBonus = Math.max(0, (20 - position) * 2);
const incidentPenalty = (incidents ?? 0) * -5;
return baseChange + positionBonus + incidentPenalty;
}
}