rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View 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));
}
}

View 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,
};
}
}

View 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;
}
}

View 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;
}

View 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[];
}

View 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;
}

View 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);
}
}

View 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';
}
}

View 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);
}
}