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