This commit is contained in:
2025-12-04 23:31:55 +01:00
parent 9fa21a488a
commit fb509607c1
96 changed files with 5839 additions and 1609 deletions

View File

@@ -0,0 +1,41 @@
import type { ParticipantRef } from '../value-objects/ParticipantRef';
export class ChampionshipStanding {
readonly seasonId: string;
readonly championshipId: string;
readonly participant: ParticipantRef;
readonly totalPoints: number;
readonly resultsCounted: number;
readonly resultsDropped: number;
readonly position: number;
constructor(props: {
seasonId: string;
championshipId: string;
participant: ParticipantRef;
totalPoints: number;
resultsCounted: number;
resultsDropped: number;
position: number;
}) {
this.seasonId = props.seasonId;
this.championshipId = props.championshipId;
this.participant = props.participant;
this.totalPoints = props.totalPoints;
this.resultsCounted = props.resultsCounted;
this.resultsDropped = props.resultsDropped;
this.position = props.position;
}
withPosition(position: number): ChampionshipStanding {
return new ChampionshipStanding({
seasonId: this.seasonId,
championshipId: this.championshipId,
participant: this.participant,
totalPoints: this.totalPoints,
resultsCounted: this.resultsCounted,
resultsDropped: this.resultsDropped,
position,
});
}
}

View File

@@ -0,0 +1,24 @@
export class Game {
readonly id: string;
readonly name: string;
private constructor(props: { id: string; name: string }) {
this.id = props.id;
this.name = props.name;
}
static create(props: { id: string; name: string }): Game {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Game ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Game name is required');
}
return new Game({
id: props.id,
name: props.name,
});
}
}

View File

@@ -10,6 +10,17 @@ export interface LeagueSettings {
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
/**
* Maximum number of drivers allowed in the league.
* Used for simple capacity display on the website.
*/
maxDrivers?: number;
}
export interface LeagueSocialLinks {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
}
export class League {
@@ -19,6 +30,7 @@ export class League {
readonly ownerId: string;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly socialLinks?: LeagueSocialLinks;
private constructor(props: {
id: string;
@@ -27,6 +39,7 @@ export class League {
ownerId: string;
settings: LeagueSettings;
createdAt: Date;
socialLinks?: LeagueSocialLinks;
}) {
this.id = props.id;
this.name = props.name;
@@ -34,6 +47,7 @@ export class League {
this.ownerId = props.ownerId;
this.settings = props.settings;
this.createdAt = props.createdAt;
this.socialLinks = props.socialLinks;
}
/**
@@ -46,6 +60,7 @@ export class League {
ownerId: string;
settings?: Partial<LeagueSettings>;
createdAt?: Date;
socialLinks?: LeagueSocialLinks;
}): League {
this.validate(props);
@@ -53,6 +68,7 @@ export class League {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
maxDrivers: 32,
};
return new League({
@@ -62,6 +78,7 @@ export class League {
ownerId: props.ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
socialLinks: props.socialLinks,
});
}
@@ -102,6 +119,7 @@ export class League {
name: string;
description: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
}>): League {
return new League({
id: this.id,
@@ -110,6 +128,7 @@ export class League {
ownerId: this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,
});
}
}

View File

@@ -0,0 +1,7 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
export interface LeagueScoringConfig {
id: string;
seasonId: string;
championships: ChampionshipConfig[];
}

View File

@@ -0,0 +1,29 @@
/**
* 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.
*/
export type PenaltyType = 'points-deduction' | 'points-bonus';
export interface Penalty {
id: string;
leagueId: string;
driverId: string;
type: PenaltyType;
/**
* Signed integer representing points adjustment:
* - negative for deductions
* - positive for bonuses
*/
pointsDelta: number;
/**
* Optional short reason/label (e.g. "Incident penalty", "Fastest laps bonus").
*/
reason?: string;
/**
* When this penalty was applied.
*/
appliedAt: Date;
}

View File

@@ -0,0 +1,77 @@
export type SeasonStatus = 'planned' | 'active' | 'completed';
export class Season {
readonly id: string;
readonly leagueId: string;
readonly gameId: string;
readonly name: string;
readonly year?: number;
readonly order?: number;
readonly status: SeasonStatus;
readonly startDate?: Date;
readonly endDate?: Date;
private constructor(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.gameId = props.gameId;
this.name = props.name;
this.year = props.year;
this.order = props.order;
this.status = props.status;
this.startDate = props.startDate;
this.endDate = props.endDate;
}
static create(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status?: SeasonStatus;
startDate?: Date;
endDate?: Date;
}): Season {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Season ID is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new Error('Season leagueId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('Season gameId is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Season name is required');
}
const status: SeasonStatus = props.status ?? 'planned';
return new Season({
id: props.id,
leagueId: props.leagueId,
gameId: props.gameId,
name: props.name,
year: props.year,
order: props.order,
status,
startDate: props.startDate,
endDate: props.endDate,
});
}
}

View File

@@ -0,0 +1,10 @@
import type { ChampionshipStanding } from '../entities/ChampionshipStanding';
export interface IChampionshipStandingRepository {
findBySeasonAndChampionship(
seasonId: string,
championshipId: string,
): Promise<ChampionshipStanding[]>;
saveAll(standings: ChampionshipStanding[]): Promise<void>;
}

View File

@@ -0,0 +1,6 @@
import type { Game } from '../entities/Game';
export interface IGameRepository {
findById(id: string): Promise<Game | null>;
findAll(): Promise<Game[]>;
}

View File

@@ -0,0 +1,5 @@
import type { LeagueScoringConfig } from '../entities/LeagueScoringConfig';
export interface ILeagueScoringConfigRepository {
findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null>;
}

View File

@@ -0,0 +1,25 @@
/**
* 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.
*/
import type { Penalty } from '../entities/Penalty';
export interface IPenaltyRepository {
/**
* Get all penalties for a given league.
*/
findByLeagueId(leagueId: string): Promise<Penalty[]>;
/**
* Get all penalties for a driver in a specific league.
*/
findByLeagueIdAndDriverId(leagueId: string, driverId: string): Promise<Penalty[]>;
/**
* Get all penalties in the system.
*/
findAll(): Promise<Penalty[]>;
}

View File

@@ -0,0 +1,6 @@
import type { Season } from '../entities/Season';
export interface ISeasonRepository {
findById(id: string): Promise<Season | null>;
findByLeagueId(leagueId: string): Promise<Season[]>;
}

View File

@@ -0,0 +1,71 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { ParticipantRef } from '../value-objects/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,56 @@
import type { DropScorePolicy } from '../value-objects/DropScorePolicy';
export interface EventPointsEntry {
eventId: string;
points: number;
}
export interface DropScoreResult {
counted: EventPointsEntry[];
dropped: EventPointsEntry[];
totalPoints: number;
}
export class DropScoreApplier {
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,128 @@
import type { ChampionshipConfig } from '../value-objects/ChampionshipConfig';
import type { SessionType } from '../value-objects/SessionType';
import type { ParticipantRef } from '../value-objects/ParticipantRef';
import type { Result } from '../entities/Result';
import type { Penalty } from '../entities/Penalty';
import type { BonusRule } from '../value-objects/BonusRule';
import type { ChampionshipType } from '../value-objects/ChampionshipType';
import type { PointsTable } from '../value-objects/PointsTable';
export interface ParticipantEventPoints {
participant: ParticipantRef;
basePoints: number;
bonusPoints: number;
penaltyPoints: number;
totalPoints: number;
}
function createDriverParticipant(driverId: string): ParticipantRef {
return {
type: 'driver' as ChampionshipType,
id: driverId,
};
}
export class EventScoringService {
scoreSession(params: {
seasonId: string;
championship: ChampionshipConfig;
sessionType: SessionType;
results: Result[];
penalties: Penalty[];
}): 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[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];
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) {
const current = map.get(penalty.driverId) ?? 0;
map.set(penalty.driverId, current + penalty.pointsDelta);
}
return map;
}
}

View File

@@ -0,0 +1,8 @@
export type BonusRuleType = 'fastestLap' | 'polePosition' | 'mostPositionsGained';
export interface BonusRule {
id: string;
type: BonusRuleType;
points: number;
requiresFinishInTopN?: number;
}

View File

@@ -0,0 +1,15 @@
import type { ChampionshipType } from './ChampionshipType';
import type { SessionType } from './SessionType';
import { PointsTable } from './PointsTable';
import type { BonusRule } from './BonusRule';
import type { DropScorePolicy } from './DropScorePolicy';
export interface ChampionshipConfig {
id: string;
name: string;
type: ChampionshipType;
sessionTypes: SessionType[];
pointsTableBySessionType: Record<SessionType, PointsTable>;
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
dropScorePolicy: DropScorePolicy;
}

View File

@@ -0,0 +1 @@
export type ChampionshipType = 'driver' | 'team' | 'nations' | 'trophy';

View File

@@ -0,0 +1,13 @@
export type DropScoreStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface DropScorePolicy {
strategy: DropScoreStrategy;
/**
* For 'bestNResults': number of best-scoring events to count.
*/
count?: number;
/**
* For 'dropWorstN': number of worst-scoring events to drop.
*/
dropCount?: number;
}

View File

@@ -0,0 +1,6 @@
import type { ChampionshipType } from './ChampionshipType';
export interface ParticipantRef {
type: ChampionshipType;
id: string;
}

View File

@@ -0,0 +1,21 @@
export class PointsTable {
private readonly pointsByPosition: Map<number, number>;
constructor(pointsByPosition: Record<number, number> | Map<number, number>) {
if (pointsByPosition instanceof Map) {
this.pointsByPosition = new Map(pointsByPosition);
} else {
this.pointsByPosition = new Map(
Object.entries(pointsByPosition).map(([key, value]) => [Number(key), value]),
);
}
}
getPointsForPosition(position: number): number {
if (!Number.isInteger(position) || position < 1) {
return 0;
}
const value = this.pointsByPosition.get(position);
return typeof value === 'number' ? value : 0;
}
}

View File

@@ -0,0 +1,9 @@
export type SessionType =
| 'practice'
| 'qualifying'
| 'q1'
| 'q2'
| 'q3'
| 'sprint'
| 'main'
| 'timeTrial';