rename to core
This commit is contained in:
71
core/racing/domain/services/ChampionshipAggregator.ts
Normal file
71
core/racing/domain/services/ChampionshipAggregator.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
|
||||
import type { ParticipantRef } from '../types/ParticipantRef';
|
||||
import { ChampionshipStanding } from '../entities/ChampionshipStanding';
|
||||
import type { ParticipantEventPoints } from './EventScoringService';
|
||||
import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier';
|
||||
|
||||
export class ChampionshipAggregator {
|
||||
constructor(private readonly dropScoreApplier: DropScoreApplier) {}
|
||||
|
||||
aggregate(params: {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
eventPointsByEventId: Record<string, ParticipantEventPoints[]>;
|
||||
}): ChampionshipStanding[] {
|
||||
const { seasonId, championship, eventPointsByEventId } = params;
|
||||
|
||||
const perParticipantEvents = new Map<
|
||||
string,
|
||||
{ participant: ParticipantRef; events: EventPointsEntry[] }
|
||||
>();
|
||||
|
||||
for (const [eventId, pointsList] of Object.entries(eventPointsByEventId)) {
|
||||
for (const entry of pointsList) {
|
||||
const key = entry.participant.id;
|
||||
const existing = perParticipantEvents.get(key);
|
||||
const eventEntry: EventPointsEntry = {
|
||||
eventId,
|
||||
points: entry.totalPoints,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
existing.events.push(eventEntry);
|
||||
} else {
|
||||
perParticipantEvents.set(key, {
|
||||
participant: entry.participant,
|
||||
events: [eventEntry],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const standings: ChampionshipStanding[] = [];
|
||||
|
||||
for (const { participant, events } of perParticipantEvents.values()) {
|
||||
const dropResult = this.dropScoreApplier.apply(
|
||||
championship.dropScorePolicy,
|
||||
events,
|
||||
);
|
||||
|
||||
const totalPoints = dropResult.totalPoints;
|
||||
const resultsCounted = dropResult.counted.length;
|
||||
const resultsDropped = dropResult.dropped.length;
|
||||
|
||||
standings.push(
|
||||
new ChampionshipStanding({
|
||||
seasonId,
|
||||
championshipId: championship.id,
|
||||
participant,
|
||||
totalPoints,
|
||||
resultsCounted,
|
||||
resultsDropped,
|
||||
position: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
standings.sort((a, b) => b.totalPoints - a.totalPoints);
|
||||
|
||||
return standings.map((s, index) => s.withPosition(index + 1));
|
||||
}
|
||||
}
|
||||
66
core/racing/domain/services/DropScoreApplier.ts
Normal file
66
core/racing/domain/services/DropScoreApplier.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { DropScorePolicy } from '../types/DropScorePolicy';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface EventPointsEntry {
|
||||
eventId: string;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface DropScoreResult {
|
||||
counted: EventPointsEntry[];
|
||||
dropped: EventPointsEntry[];
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export interface DropScoreInput {
|
||||
policy: DropScorePolicy;
|
||||
events: EventPointsEntry[];
|
||||
}
|
||||
|
||||
export class DropScoreApplier implements IDomainCalculationService<DropScoreInput, DropScoreResult> {
|
||||
calculate(input: DropScoreInput): DropScoreResult {
|
||||
return this.apply(input.policy, input.events);
|
||||
}
|
||||
|
||||
apply(policy: DropScorePolicy, events: EventPointsEntry[]): DropScoreResult {
|
||||
if (policy.strategy === 'none' || events.length === 0) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
if (policy.strategy === 'bestNResults') {
|
||||
const count = policy.count ?? events.length;
|
||||
if (count >= events.length) {
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = [...events].sort((a, b) => b.points - a.points);
|
||||
const counted = sorted.slice(0, count);
|
||||
const dropped = sorted.slice(count);
|
||||
const totalPoints = counted.reduce((sum, e) => sum + e.points, 0);
|
||||
|
||||
return {
|
||||
counted,
|
||||
dropped,
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
|
||||
// For this slice, treat unsupported strategies as 'none'
|
||||
const totalPoints = events.reduce((sum, e) => sum + e.points, 0);
|
||||
return {
|
||||
counted: [...events],
|
||||
dropped: [],
|
||||
totalPoints,
|
||||
};
|
||||
}
|
||||
}
|
||||
147
core/racing/domain/services/EventScoringService.ts
Normal file
147
core/racing/domain/services/EventScoringService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
|
||||
import type { SessionType } from '../types/SessionType';
|
||||
import type { ParticipantRef } from '../types/ParticipantRef';
|
||||
import type { Result } from '../entities/Result';
|
||||
import type { Penalty } from '../entities/Penalty';
|
||||
import type { BonusRule } from '../types/BonusRule';
|
||||
import type { ChampionshipType } from '../types/ChampionshipType';
|
||||
|
||||
import type { PointsTable } from '../value-objects/PointsTable';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface ParticipantEventPoints {
|
||||
participant: ParticipantRef;
|
||||
basePoints: number;
|
||||
bonusPoints: number;
|
||||
penaltyPoints: number;
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
export interface EventScoringInput {
|
||||
seasonId: string;
|
||||
championship: ChampionshipConfig;
|
||||
sessionType: SessionType;
|
||||
results: Result[];
|
||||
penalties: Penalty[];
|
||||
}
|
||||
|
||||
function createDriverParticipant(driverId: string): ParticipantRef {
|
||||
return {
|
||||
type: 'driver' as ChampionshipType,
|
||||
id: driverId,
|
||||
};
|
||||
}
|
||||
|
||||
export class EventScoringService
|
||||
implements IDomainCalculationService<EventScoringInput, ParticipantEventPoints[]>
|
||||
{
|
||||
calculate(input: EventScoringInput): ParticipantEventPoints[] {
|
||||
return this.scoreSession(input);
|
||||
}
|
||||
|
||||
scoreSession(params: EventScoringInput): ParticipantEventPoints[] {
|
||||
const { championship, sessionType, results } = params;
|
||||
|
||||
const pointsTable = this.getPointsTableForSession(championship, sessionType);
|
||||
const bonusRules = this.getBonusRulesForSession(championship, sessionType);
|
||||
|
||||
const baseByDriver = new Map<string, number>();
|
||||
const bonusByDriver = new Map<string, number>();
|
||||
const penaltyByDriver = new Map<string, number>();
|
||||
|
||||
for (const result of results) {
|
||||
const driverId = result.driverId;
|
||||
const currentBase = baseByDriver.get(driverId) ?? 0;
|
||||
const added = pointsTable.getPointsForPosition(result.position);
|
||||
baseByDriver.set(driverId, currentBase + added);
|
||||
}
|
||||
|
||||
const fastestLapRule = bonusRules.find((r) => r.type === 'fastestLap');
|
||||
if (fastestLapRule) {
|
||||
this.applyFastestLapBonus(fastestLapRule, results, bonusByDriver);
|
||||
}
|
||||
|
||||
const penaltyMap = this.aggregatePenalties(params.penalties);
|
||||
for (const [driverId, value] of penaltyMap.entries()) {
|
||||
penaltyByDriver.set(driverId, value);
|
||||
}
|
||||
|
||||
const allDriverIds = new Set<string>();
|
||||
for (const id of baseByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of bonusByDriver.keys()) allDriverIds.add(id);
|
||||
for (const id of penaltyByDriver.keys()) allDriverIds.add(id);
|
||||
|
||||
const participants: ParticipantEventPoints[] = [];
|
||||
for (const driverId of allDriverIds) {
|
||||
const basePoints = baseByDriver.get(driverId) ?? 0;
|
||||
const bonusPoints = bonusByDriver.get(driverId) ?? 0;
|
||||
const penaltyPoints = penaltyByDriver.get(driverId) ?? 0;
|
||||
const totalPoints = basePoints + bonusPoints - penaltyPoints;
|
||||
|
||||
participants.push({
|
||||
participant: createDriverParticipant(driverId),
|
||||
basePoints,
|
||||
bonusPoints,
|
||||
penaltyPoints,
|
||||
totalPoints,
|
||||
});
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
private getPointsTableForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): PointsTable {
|
||||
return championship.pointsTableBySessionType[sessionType];
|
||||
}
|
||||
|
||||
private getBonusRulesForSession(
|
||||
championship: ChampionshipConfig,
|
||||
sessionType: SessionType,
|
||||
): BonusRule[] {
|
||||
const all = championship.bonusRulesBySessionType ?? {};
|
||||
return (all as Record<SessionType, BonusRule[]>)[sessionType] ?? [];
|
||||
}
|
||||
|
||||
private applyFastestLapBonus(
|
||||
rule: BonusRule,
|
||||
results: Result[],
|
||||
bonusByDriver: Map<string, number>,
|
||||
): void {
|
||||
if (results.length === 0) return;
|
||||
|
||||
const sortedByLap = [...results].sort((a, b) => a.fastestLap - b.fastestLap);
|
||||
const best = sortedByLap[0];
|
||||
|
||||
if (!best) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresTop = rule.requiresFinishInTopN;
|
||||
if (typeof requiresTop === 'number') {
|
||||
if (best.position <= 0 || best.position > requiresTop) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const current = bonusByDriver.get(best.driverId) ?? 0;
|
||||
bonusByDriver.set(best.driverId, current + rule.points);
|
||||
}
|
||||
|
||||
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;
|
||||
const delta = penalty.value ?? 0;
|
||||
map.set(penalty.driverId, current + delta);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
13
core/racing/domain/services/IDriverStatsService.ts
Normal file
13
core/racing/domain/services/IDriverStatsService.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface DriverStats {
|
||||
rating: number;
|
||||
wins: number;
|
||||
podiums: number;
|
||||
totalRaces: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IDriverStatsService extends IDomainService {
|
||||
getDriverStats(driverId: string): DriverStats | null;
|
||||
}
|
||||
11
core/racing/domain/services/IRankingService.ts
Normal file
11
core/racing/domain/services/IRankingService.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export interface DriverRanking {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
overallRank: number | null;
|
||||
}
|
||||
|
||||
export interface IRankingService extends IDomainService {
|
||||
getAllDriverRankings(): DriverRanking[];
|
||||
}
|
||||
147
core/racing/domain/services/ScheduleCalculator.ts
Normal file
147
core/racing/domain/services/ScheduleCalculator.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
|
||||
export type RecurrenceStrategy = 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
weekdays: Weekday[];
|
||||
frequency: RecurrenceStrategy;
|
||||
rounds: number;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
intervalWeeks?: number;
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
raceDates: Date[];
|
||||
seasonDurationWeeks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
||||
*/
|
||||
const DAY_MAP: Record<Weekday, number> = {
|
||||
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate race dates based on schedule configuration.
|
||||
*
|
||||
* If both startDate and endDate are provided, races are evenly distributed
|
||||
* across the selected weekdays within that range.
|
||||
*
|
||||
* If only startDate is provided, races are scheduled according to the
|
||||
* recurrence strategy (weekly or bi-weekly).
|
||||
*/
|
||||
export function calculateRaceDates(config: ScheduleConfig): ScheduleResult {
|
||||
const { weekdays, frequency, rounds, startDate, endDate, intervalWeeks } = config;
|
||||
const dates: Date[] = [];
|
||||
|
||||
if (weekdays.length === 0 || rounds <= 0) {
|
||||
return { raceDates: [], seasonDurationWeeks: 0 };
|
||||
}
|
||||
|
||||
// Convert weekday names to day numbers for faster lookup
|
||||
const selectedDayNumbers = new Set(weekdays.map(wd => DAY_MAP[wd]));
|
||||
|
||||
// If we have both start and end dates, evenly distribute races
|
||||
if (endDate && endDate > startDate) {
|
||||
const allPossibleDays: Date[] = [];
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(12, 0, 0, 0);
|
||||
|
||||
const endDateTime = new Date(endDate);
|
||||
endDateTime.setHours(12, 0, 0, 0);
|
||||
|
||||
while (currentDate <= endDateTime) {
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
if (selectedDayNumbers.has(dayOfWeek)) {
|
||||
allPossibleDays.push(new Date(currentDate));
|
||||
}
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
// Evenly distribute the rounds across available days
|
||||
const totalPossible = allPossibleDays.length;
|
||||
if (totalPossible >= rounds) {
|
||||
const spacing = totalPossible / rounds;
|
||||
for (let i = 0; i < rounds; i++) {
|
||||
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
||||
dates.push(allPossibleDays[index]!);
|
||||
}
|
||||
} else {
|
||||
// Not enough days - use all available
|
||||
dates.push(...allPossibleDays);
|
||||
}
|
||||
|
||||
const seasonDurationWeeks = dates.length > 1
|
||||
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||
: 0;
|
||||
|
||||
return { raceDates: dates, seasonDurationWeeks };
|
||||
}
|
||||
|
||||
// Schedule based on frequency (no end date)
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(12, 0, 0, 0);
|
||||
let roundsScheduled = 0;
|
||||
|
||||
// Generate race dates for up to 2 years to ensure we can schedule all rounds
|
||||
const maxDays = 365 * 2;
|
||||
let daysChecked = 0;
|
||||
const seasonStart = new Date(startDate);
|
||||
seasonStart.setHours(12, 0, 0, 0);
|
||||
|
||||
while (roundsScheduled < rounds && daysChecked < maxDays) {
|
||||
const dayOfWeek = currentDate.getDay();
|
||||
const isSelectedDay = selectedDayNumbers.has(dayOfWeek);
|
||||
|
||||
// Calculate which week this is from the start
|
||||
const daysSinceStart = Math.floor((currentDate.getTime() - seasonStart.getTime()) / (24 * 60 * 60 * 1000));
|
||||
const currentWeek = Math.floor(daysSinceStart / 7);
|
||||
|
||||
if (isSelectedDay) {
|
||||
let shouldRace = false;
|
||||
|
||||
if (frequency === 'weekly') {
|
||||
// Weekly: race every week on selected days
|
||||
shouldRace = true;
|
||||
} else if (frequency === 'everyNWeeks') {
|
||||
// Every N weeks: race only on matching week intervals
|
||||
const interval = intervalWeeks ?? 2;
|
||||
shouldRace = currentWeek % interval === 0;
|
||||
} else {
|
||||
// Default to weekly if frequency not set
|
||||
shouldRace = true;
|
||||
}
|
||||
|
||||
if (shouldRace) {
|
||||
dates.push(new Date(currentDate));
|
||||
roundsScheduled++;
|
||||
}
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
daysChecked++;
|
||||
}
|
||||
|
||||
const seasonDurationWeeks = dates.length > 1
|
||||
? Math.ceil((dates[dates.length - 1]!.getTime() - dates[0]!.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
||||
: 0;
|
||||
|
||||
return { raceDates: dates, seasonDurationWeeks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next occurrence of a specific weekday from a given date.
|
||||
*/
|
||||
export function getNextWeekday(fromDate: Date, weekday: Weekday): Date {
|
||||
const targetDay = DAY_MAP[weekday];
|
||||
const result = new Date(fromDate);
|
||||
result.setHours(12, 0, 0, 0);
|
||||
|
||||
const currentDay = result.getDay();
|
||||
const daysUntilTarget = (targetDay - currentDay + 7) % 7 || 7;
|
||||
|
||||
result.setDate(result.getDate() + daysUntilTarget);
|
||||
return result;
|
||||
}
|
||||
185
core/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
185
core/racing/domain/services/SeasonScheduleGenerator.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
||||
import { ScheduledRaceSlot } from '../value-objects/ScheduledRaceSlot';
|
||||
import type { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
||||
import { RacingDomainError, RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
||||
import type { Weekday } from '../types/Weekday';
|
||||
import { weekdayToIndex } from '../types/Weekday';
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
function cloneDate(date: Date): Date {
|
||||
return new Date(date.getTime());
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = cloneDate(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addWeeks(date: Date, weeks: number): Date {
|
||||
return addDays(date, weeks * 7);
|
||||
}
|
||||
|
||||
function addMonths(date: Date, months: number): Date {
|
||||
const d = cloneDate(date);
|
||||
const targetMonth = d.getMonth() + months;
|
||||
d.setMonth(targetMonth);
|
||||
return d;
|
||||
}
|
||||
|
||||
function applyTimeOfDay(baseDate: Date, timeOfDay: RaceTimeOfDay): Date {
|
||||
const d = new Date(
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth(),
|
||||
baseDate.getDate(),
|
||||
timeOfDay.hour,
|
||||
timeOfDay.minute,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
return d;
|
||||
}
|
||||
|
||||
// Treat Monday as 1 ... Sunday as 7
|
||||
function getCalendarWeekdayIndex(date: Date): number {
|
||||
const jsDay = date.getDay(); // 0=Sun ... 6=Sat
|
||||
if (jsDay === 0) {
|
||||
return 7;
|
||||
}
|
||||
return jsDay;
|
||||
}
|
||||
|
||||
function weekdayToCalendarOffset(anchor: Date, target: Weekday): number {
|
||||
const anchorIndex = getCalendarWeekdayIndex(anchor);
|
||||
const targetIndex = weekdayToIndex(target);
|
||||
return targetIndex - anchorIndex;
|
||||
}
|
||||
|
||||
function generateWeeklyOrEveryNWeeksSlots(
|
||||
schedule: SeasonSchedule,
|
||||
maxRounds: number,
|
||||
): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
const weekdays =
|
||||
recurrence.kind === 'weekly' || recurrence.kind === 'everyNWeeks'
|
||||
? recurrence.weekdays.getAll()
|
||||
: [];
|
||||
|
||||
if (weekdays.length === 0) {
|
||||
throw new RacingDomainValidationError('RecurrenceStrategy has no weekdays');
|
||||
}
|
||||
|
||||
const intervalWeeks = recurrence.kind === 'everyNWeeks' ? recurrence.intervalWeeks : 1;
|
||||
|
||||
let anchorWeekStart = cloneDate(schedule.startDate);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
for (const weekday of weekdays) {
|
||||
const offset = weekdayToCalendarOffset(anchorWeekStart, weekday);
|
||||
const candidateDate = addDays(anchorWeekStart, offset);
|
||||
|
||||
if (candidateDate < schedule.startDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
|
||||
if (result.length >= maxRounds) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
anchorWeekStart = addWeeks(anchorWeekStart, intervalWeeks);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findNthWeekdayOfMonth(base: Date, ordinal: 1 | 2 | 3 | 4, weekday: Weekday): Date {
|
||||
const firstOfMonth = new Date(base.getFullYear(), base.getMonth(), 1);
|
||||
const firstIndex = getCalendarWeekdayIndex(firstOfMonth);
|
||||
const targetIndex = weekdayToIndex(weekday);
|
||||
|
||||
let offset = targetIndex - firstIndex;
|
||||
if (offset < 0) {
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
const dayOfMonth = 1 + offset + (ordinal - 1) * 7;
|
||||
return new Date(base.getFullYear(), base.getMonth(), dayOfMonth);
|
||||
}
|
||||
|
||||
function generateMonthlySlots(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
const result: ScheduledRaceSlot[] = [];
|
||||
const recurrence = schedule.recurrence;
|
||||
if (recurrence.kind !== 'monthlyNthWeekday') {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { ordinal, weekday } = recurrence.monthlyPattern;
|
||||
let currentMonthDate = new Date(
|
||||
schedule.startDate.getFullYear(),
|
||||
schedule.startDate.getMonth(),
|
||||
1,
|
||||
);
|
||||
let roundNumber = 1;
|
||||
|
||||
while (result.length < maxRounds) {
|
||||
const candidateDate = findNthWeekdayOfMonth(currentMonthDate, ordinal, weekday);
|
||||
|
||||
if (candidateDate >= schedule.startDate) {
|
||||
const scheduledAt = applyTimeOfDay(candidateDate, schedule.timeOfDay);
|
||||
result.push(
|
||||
new ScheduledRaceSlot({
|
||||
roundNumber,
|
||||
scheduledAt,
|
||||
timezone: schedule.timezone,
|
||||
}),
|
||||
);
|
||||
roundNumber += 1;
|
||||
}
|
||||
|
||||
currentMonthDate = addMonths(currentMonthDate, 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export class SeasonScheduleGenerator {
|
||||
static generateSlots(schedule: SeasonSchedule): ScheduledRaceSlot[] {
|
||||
return this.generateSlotsUpTo(schedule, schedule.plannedRounds);
|
||||
}
|
||||
|
||||
static generateSlotsUpTo(schedule: SeasonSchedule, maxRounds: number): ScheduledRaceSlot[] {
|
||||
if (!Number.isInteger(maxRounds) || maxRounds <= 0) {
|
||||
throw new RacingDomainValidationError('maxRounds must be a positive integer');
|
||||
}
|
||||
|
||||
const recurrence: RecurrenceStrategy = schedule.recurrence;
|
||||
|
||||
if (recurrence.kind === 'monthlyNthWeekday') {
|
||||
return generateMonthlySlots(schedule, maxRounds);
|
||||
}
|
||||
|
||||
return generateWeeklyOrEveryNWeeksSlots(schedule, maxRounds);
|
||||
}
|
||||
}
|
||||
|
||||
export class SeasonScheduleGeneratorService
|
||||
implements IDomainCalculationService<SeasonSchedule, ScheduledRaceSlot[]>
|
||||
{
|
||||
calculate(schedule: SeasonSchedule): ScheduledRaceSlot[] {
|
||||
return SeasonScheduleGenerator.generateSlots(schedule);
|
||||
}
|
||||
}
|
||||
35
core/racing/domain/services/SkillLevelService.ts
Normal file
35
core/racing/domain/services/SkillLevelService.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { IDomainService } from '@gridpilot/shared/domain';
|
||||
|
||||
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
|
||||
/**
|
||||
* Domain service for determining skill level based on rating.
|
||||
* This encapsulates the business rule for skill tier classification.
|
||||
*/
|
||||
export class SkillLevelService implements IDomainService {
|
||||
readonly serviceName = 'SkillLevelService';
|
||||
/**
|
||||
* Map driver rating to skill level band.
|
||||
* Business rule: iRating thresholds determine skill tiers.
|
||||
*/
|
||||
static getSkillLevel(rating: number): SkillLevel {
|
||||
if (rating >= 3000) return 'pro';
|
||||
if (rating >= 2500) return 'advanced';
|
||||
if (rating >= 1800) return 'intermediate';
|
||||
return 'beginner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map average team rating to performance level.
|
||||
* Business rule: Team ratings use higher thresholds than individual drivers.
|
||||
*/
|
||||
static getTeamPerformanceLevel(averageRating: number | null): SkillLevel {
|
||||
if (averageRating === null) {
|
||||
return 'beginner';
|
||||
}
|
||||
if (averageRating >= 4500) return 'pro';
|
||||
if (averageRating >= 3000) return 'advanced';
|
||||
if (averageRating >= 2000) return 'intermediate';
|
||||
return 'beginner';
|
||||
}
|
||||
}
|
||||
43
core/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
43
core/racing/domain/services/StrengthOfFieldCalculator.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IDomainCalculationService } from '@gridpilot/shared/domain';
|
||||
|
||||
/**
|
||||
* Domain Service: StrengthOfFieldCalculator
|
||||
*
|
||||
* Calculates the Strength of Field (SOF) for a race based on participant ratings.
|
||||
* SOF is the average rating of all participants in a race.
|
||||
*/
|
||||
|
||||
export interface DriverRating {
|
||||
driverId: string;
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export interface StrengthOfFieldCalculator {
|
||||
/**
|
||||
* Calculate SOF from a list of driver ratings
|
||||
* Returns null if no valid ratings are provided
|
||||
*/
|
||||
calculate(driverRatings: DriverRating[]): number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation using simple average
|
||||
*/
|
||||
export class AverageStrengthOfFieldCalculator
|
||||
implements StrengthOfFieldCalculator, IDomainCalculationService<DriverRating[], number | null>
|
||||
{
|
||||
calculate(driverRatings: DriverRating[]): number | null {
|
||||
if (driverRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validRatings = driverRatings.filter(dr => dr.rating > 0);
|
||||
|
||||
if (validRatings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sum = validRatings.reduce((acc, dr) => acc + dr.rating, 0);
|
||||
return Math.round(sum / validRatings.length);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user