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,203 @@
/**
* Domain Value Object: GameConstraints
*
* Represents game-specific constraints for leagues.
* Different sim racing games have different maximum grid sizes.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
export interface GameConstraintsData {
readonly maxDrivers: number;
readonly maxTeams: number;
readonly defaultMaxDrivers: number;
readonly minDrivers: number;
readonly supportsTeams: boolean;
readonly supportsMultiClass: boolean;
}
export interface GameConstraintsProps {
gameId: string;
constraints: GameConstraintsData;
}
/**
* Game-specific constraints for popular sim racing games
*/
const GAME_CONSTRAINTS: Record<string, GameConstraintsData> & { default: GameConstraintsData } = {
iracing: {
maxDrivers: 64,
maxTeams: 32,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
acc: {
maxDrivers: 30,
maxTeams: 15,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: false,
},
rf2: {
maxDrivers: 64,
maxTeams: 32,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
ams2: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 20,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
lmu: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 24,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: true,
},
// Default for unknown games
default: {
maxDrivers: 32,
maxTeams: 16,
defaultMaxDrivers: 20,
minDrivers: 2,
supportsTeams: true,
supportsMultiClass: false,
},
};
function getConstraintsForId(gameId: string): GameConstraintsData {
const lower = gameId.toLowerCase();
const fromMap = GAME_CONSTRAINTS[lower];
if (fromMap) {
return fromMap;
}
return GAME_CONSTRAINTS.default;
}
export class GameConstraints implements IValueObject<GameConstraintsProps> {
readonly gameId: string;
readonly constraints: GameConstraintsData;
private constructor(gameId: string, constraints: GameConstraintsData) {
this.gameId = gameId;
this.constraints = constraints;
}
get props(): GameConstraintsProps {
return {
gameId: this.gameId,
constraints: this.constraints,
};
}
equals(other: IValueObject<GameConstraintsProps>): boolean {
return this.props.gameId === other.props.gameId;
}
/**
* Get constraints for a specific game
*/
static forGame(gameId: string): GameConstraints {
const constraints = getConstraintsForId(gameId);
const lowerId = gameId.toLowerCase();
return new GameConstraints(lowerId, constraints);
}
/**
* Get all supported game IDs
*/
static getSupportedGames(): string[] {
return Object.keys(GAME_CONSTRAINTS).filter(id => id !== 'default');
}
/**
* Maximum drivers allowed for this game
*/
get maxDrivers(): number {
return this.constraints.maxDrivers;
}
/**
* Maximum teams allowed for this game
*/
get maxTeams(): number {
return this.constraints.maxTeams;
}
/**
* Default driver count for new leagues
*/
get defaultMaxDrivers(): number {
return this.constraints.defaultMaxDrivers;
}
/**
* Minimum drivers required
*/
get minDrivers(): number {
return this.constraints.minDrivers;
}
/**
* Whether this game supports team-based leagues
*/
get supportsTeams(): boolean {
return this.constraints.supportsTeams;
}
/**
* Whether this game supports multi-class racing
*/
get supportsMultiClass(): boolean {
return this.constraints.supportsMultiClass;
}
/**
* Validate a driver count against game constraints
*/
validateDriverCount(count: number): { valid: boolean; error?: string } {
if (count < this.minDrivers) {
return {
valid: false,
error: `Minimum ${this.minDrivers} drivers required`,
};
}
if (count > this.maxDrivers) {
return {
valid: false,
error: `Maximum ${this.maxDrivers} drivers allowed for ${this.gameId.toUpperCase()}`,
};
}
return { valid: true };
}
/**
* Validate a team count against game constraints
*/
validateTeamCount(count: number): { valid: boolean; error?: string } {
if (!this.supportsTeams) {
return {
valid: false,
error: `${this.gameId.toUpperCase()} does not support team-based leagues`,
};
}
if (count > this.maxTeams) {
return {
valid: false,
error: `Maximum ${this.maxTeams} teams allowed for ${this.gameId.toUpperCase()}`,
};
}
return { valid: true };
}
}

View File

@@ -0,0 +1,100 @@
/**
* Domain Value Object: LeagueDescription
*
* Represents a valid league description with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueDescriptionValidationResult {
valid: boolean;
error?: string;
}
export const LEAGUE_DESCRIPTION_CONSTRAINTS = {
minLength: 20,
maxLength: 1000,
recommendedMinLength: 50,
} as const;
export interface LeagueDescriptionProps {
value: string;
}
export class LeagueDescription implements IValueObject<LeagueDescriptionProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
/**
* Validate a league description without creating the value object
*/
static validate(value: string): LeagueDescriptionValidationResult {
const trimmed = value?.trim() ?? '';
if (!trimmed) {
return { valid: false, error: 'Description is required — help drivers understand your league' };
}
if (trimmed.length < LEAGUE_DESCRIPTION_CONSTRAINTS.minLength) {
return {
valid: false,
error: `Description must be at least ${LEAGUE_DESCRIPTION_CONSTRAINTS.minLength} characters — tell drivers what makes your league special`,
};
}
if (trimmed.length > LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength) {
return {
valid: false,
error: `Description must be ${LEAGUE_DESCRIPTION_CONSTRAINTS.maxLength} characters or less`,
};
}
return { valid: true };
}
/**
* Check if description meets recommended length for better engagement
*/
static isRecommendedLength(value: string): boolean {
const trimmed = value?.trim() ?? '';
return trimmed.length >= LEAGUE_DESCRIPTION_CONSTRAINTS.recommendedMinLength;
}
/**
* Create a LeagueDescription from a string value
*/
static create(value: string): LeagueDescription {
const validation = this.validate(value);
if (!validation.valid) {
throw new RacingDomainValidationError(validation.error ?? 'Invalid league description');
}
return new LeagueDescription(value.trim());
}
get props(): LeagueDescriptionProps {
return { value: this.value };
}
/**
* Try to create a LeagueDescription, returning null if invalid
*/
static tryCreate(value: string): LeagueDescription | null {
const validation = this.validate(value);
if (!validation.valid) {
return null;
}
return new LeagueDescription(value.trim());
}
toString(): string {
return this.value;
}
equals(other: IValueObject<LeagueDescriptionProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Domain Value Object: LeagueName
*
* Represents a valid league name with validation rules.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueNameValidationResult {
valid: boolean;
error?: string;
}
export const LEAGUE_NAME_CONSTRAINTS = {
minLength: 3,
maxLength: 64,
pattern: /^[a-zA-Z0-9].*$/, // Must start with alphanumeric
forbiddenPatterns: [
/^\s/, // No leading whitespace
/\s$/, // No trailing whitespace
/\s{2,}/, // No multiple consecutive spaces
],
} as const;
export interface LeagueNameProps {
value: string;
}
export class LeagueName implements IValueObject<LeagueNameProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
/**
* Validate a league name without creating the value object
*/
static validate(value: string): LeagueNameValidationResult {
const trimmed = value?.trim() ?? '';
if (!trimmed) {
return { valid: false, error: 'League name is required' };
}
if (trimmed.length < LEAGUE_NAME_CONSTRAINTS.minLength) {
return {
valid: false,
error: `League name must be at least ${LEAGUE_NAME_CONSTRAINTS.minLength} characters`,
};
}
if (trimmed.length > LEAGUE_NAME_CONSTRAINTS.maxLength) {
return {
valid: false,
error: `League name must be ${LEAGUE_NAME_CONSTRAINTS.maxLength} characters or less`,
};
}
if (!LEAGUE_NAME_CONSTRAINTS.pattern.test(trimmed)) {
return {
valid: false,
error: 'League name must start with a letter or number',
};
}
for (const forbidden of LEAGUE_NAME_CONSTRAINTS.forbiddenPatterns) {
if (forbidden.test(value)) {
return {
valid: false,
error: 'League name cannot have leading/trailing spaces or multiple consecutive spaces',
};
}
}
return { valid: true };
}
/**
* Create a LeagueName from a string value
*/
static create(value: string): LeagueName {
const validation = this.validate(value);
if (!validation.valid) {
throw new RacingDomainValidationError(validation.error ?? 'Invalid league name');
}
return new LeagueName(value.trim());
}
get props(): LeagueNameProps {
return { value: this.value };
}
/**
* Try to create a LeagueName, returning null if invalid
*/
static tryCreate(value: string): LeagueName | null {
const validation = this.validate(value);
if (!validation.valid) {
return null;
}
return new LeagueName(value.trim());
}
toString(): string {
return this.value;
}
equals(other: IValueObject<LeagueNameProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,29 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface LeagueTimezoneProps {
id: string;
}
export class LeagueTimezone implements IValueObject<LeagueTimezoneProps> {
private readonly id: string;
constructor(id: string) {
if (!id || id.trim().length === 0) {
throw new RacingDomainValidationError('LeagueTimezone id must be a non-empty string');
}
this.id = id;
}
getId(): string {
return this.id;
}
get props(): LeagueTimezoneProps {
return { id: this.id };
}
equals(other: IValueObject<LeagueTimezoneProps>): boolean {
return this.props.id === other.props.id;
}
}

View File

@@ -0,0 +1,140 @@
/**
* Domain Value Object: LeagueVisibility
*
* Represents the visibility and ranking status of a league.
*
* - 'ranked' (public): Competitive leagues visible to everyone, affects driver ratings.
* Requires minimum 10 players to ensure competitive integrity.
* - 'unranked' (private): Casual leagues for friends/private groups, no rating impact.
* Can have any number of players.
*/
import type { IValueObject } from '@gridpilot/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type LeagueVisibilityType = 'ranked' | 'unranked';
export interface LeagueVisibilityConstraints {
readonly minDrivers: number;
readonly isPubliclyVisible: boolean;
readonly affectsRatings: boolean;
readonly requiresApproval: boolean;
}
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
ranked: {
minDrivers: 10,
isPubliclyVisible: true,
affectsRatings: true,
requiresApproval: false, // Anyone can join public leagues
},
unranked: {
minDrivers: 2,
isPubliclyVisible: false,
affectsRatings: false,
requiresApproval: true, // Private leagues require invite/approval
},
};
export interface LeagueVisibilityProps {
type: LeagueVisibilityType;
}
export class LeagueVisibility implements IValueObject<LeagueVisibilityProps> {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
private constructor(type: LeagueVisibilityType) {
this.type = type;
this.constraints = VISIBILITY_CONSTRAINTS[type];
}
static ranked(): LeagueVisibility {
return new LeagueVisibility('ranked');
}
static unranked(): LeagueVisibility {
return new LeagueVisibility('unranked');
}
static fromString(value: string): LeagueVisibility {
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
if (value === 'ranked' || value === 'public') {
return LeagueVisibility.ranked();
}
if (value === 'unranked' || value === 'private') {
return LeagueVisibility.unranked();
}
throw new RacingDomainValidationError(`Invalid league visibility: ${value}`);
}
/**
* Validates that the given driver count meets the minimum requirement
* for this visibility type.
*/
validateDriverCount(driverCount: number): { valid: boolean; error?: string } {
if (driverCount < this.constraints.minDrivers) {
return {
valid: false,
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`,
};
}
return { valid: true };
}
/**
* Returns true if this is a ranked/public league
*/
isRanked(): boolean {
return this.type === 'ranked';
}
/**
* Returns true if this is an unranked/private league
*/
isUnranked(): boolean {
return this.type === 'unranked';
}
/**
* Human-readable label for UI display
*/
getLabel(): string {
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
}
/**
* Short description for UI tooltips
*/
getDescription(): string {
return this.type === 'ranked'
? 'Competitive league visible to everyone. Results affect driver ratings.'
: 'Private league for friends. Results do not affect ratings.';
}
/**
* Convert to string for serialization
*/
toString(): LeagueVisibilityType {
return this.type;
}
get props(): LeagueVisibilityProps {
return { type: this.type };
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
toLegacyString(): 'public' | 'private' {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: IValueObject<LeagueVisibilityProps>): boolean {
return this.props.type === other.props.type;
}
}
// Export constants for validation
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;

View File

@@ -0,0 +1,190 @@
/**
* Value Object: LiveryDecal
* Represents a decal/logo placed on a livery
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type DecalType = 'sponsor' | 'user';
export interface LiveryDecalProps {
id: string;
imageUrl: string;
x: number;
y: number;
width: number;
height: number;
rotation: number; // Degrees, 0-360
zIndex: number;
type: DecalType;
}
export class LiveryDecal implements IValueObject<LiveryDecalProps> {
readonly id: string;
readonly imageUrl: string;
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
readonly rotation: number;
readonly zIndex: number;
readonly type: DecalType;
private constructor(props: LiveryDecalProps) {
this.id = props.id;
this.imageUrl = props.imageUrl;
this.x = props.x;
this.y = props.y;
this.width = props.width;
this.height = props.height;
this.rotation = props.rotation;
this.zIndex = props.zIndex;
this.type = props.type;
}
static create(props: Omit<LiveryDecalProps, 'rotation'> & { rotation?: number }): LiveryDecal {
const propsWithRotation = {
...props,
rotation: props.rotation ?? 0,
};
this.validate(propsWithRotation);
return new LiveryDecal(propsWithRotation);
}
private static validate(props: LiveryDecalProps): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('LiveryDecal ID is required');
}
if (!props.imageUrl || props.imageUrl.trim().length === 0) {
throw new RacingDomainValidationError('LiveryDecal imageUrl is required');
}
if (props.x < 0 || props.x > 1) {
throw new RacingDomainValidationError('LiveryDecal x coordinate must be between 0 and 1 (normalized)');
}
if (props.y < 0 || props.y > 1) {
throw new RacingDomainValidationError('LiveryDecal y coordinate must be between 0 and 1 (normalized)');
}
if (props.width <= 0 || props.width > 1) {
throw new RacingDomainValidationError('LiveryDecal width must be between 0 and 1 (normalized)');
}
if (props.height <= 0 || props.height > 1) {
throw new RacingDomainValidationError('LiveryDecal height must be between 0 and 1 (normalized)');
}
if (!Number.isInteger(props.zIndex) || props.zIndex < 0) {
throw new RacingDomainValidationError('LiveryDecal zIndex must be a non-negative integer');
}
if (props.rotation < 0 || props.rotation > 360) {
throw new RacingDomainValidationError('LiveryDecal rotation must be between 0 and 360 degrees');
}
if (!props.type) {
throw new RacingDomainValidationError('LiveryDecal type is required');
}
}
/**
* Move decal to new position
*/
moveTo(x: number, y: number): LiveryDecal {
return LiveryDecal.create({
...this,
x,
y,
});
}
/**
* Resize decal
*/
resize(width: number, height: number): LiveryDecal {
return LiveryDecal.create({
...this,
width,
height,
});
}
/**
* Change z-index
*/
setZIndex(zIndex: number): LiveryDecal {
return LiveryDecal.create({
...this,
zIndex,
});
}
/**
* Rotate decal
*/
rotate(rotation: number): LiveryDecal {
// Normalize rotation to 0-360 range
const normalizedRotation = ((rotation % 360) + 360) % 360;
return LiveryDecal.create({
...this,
rotation: normalizedRotation,
});
}
/**
* Get CSS transform string for rendering
*/
getCssTransform(): string {
return `rotate(${this.rotation}deg)`;
}
get props(): LiveryDecalProps {
return {
id: this.id,
imageUrl: this.imageUrl,
x: this.x,
y: this.y,
width: this.width,
height: this.height,
rotation: this.rotation,
zIndex: this.zIndex,
type: this.type,
};
}
/**
* Check if this decal overlaps with another
*/
overlapsWith(other: LiveryDecal): boolean {
const thisRight = this.x + this.width;
const thisBottom = this.y + this.height;
const otherRight = other.x + other.width;
const otherBottom = other.y + other.height;
return !(
thisRight <= other.x ||
this.x >= otherRight ||
thisBottom <= other.y ||
this.y >= otherBottom
);
}
equals(other: IValueObject<LiveryDecalProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.id === b.id &&
a.imageUrl === b.imageUrl &&
a.x === b.x &&
a.y === b.y &&
a.width === b.width &&
a.height === b.height &&
a.rotation === b.rotation &&
a.zIndex === b.zIndex &&
a.type === b.type
);
}
}

View File

@@ -0,0 +1,90 @@
/**
* Value Object: MembershipFee
* Represents membership fee configuration for league drivers
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export type MembershipFeeType = 'season' | 'monthly' | 'per_race';
export interface MembershipFeeProps {
type: MembershipFeeType;
amount: Money;
}
export class MembershipFee implements IValueObject<MembershipFeeProps> {
readonly type: MembershipFeeType;
readonly amount: Money;
private constructor(props: MembershipFeeProps) {
this.type = props.type;
this.amount = props.amount;
}
static create(type: MembershipFeeType, amount: Money): MembershipFee {
if (!type) {
throw new RacingDomainValidationError('MembershipFee type is required');
}
if (!amount) {
throw new RacingDomainValidationError('MembershipFee amount is required');
}
if (amount.amount < 0) {
throw new RacingDomainValidationError('MembershipFee amount cannot be negative');
}
return new MembershipFee({ type, amount });
}
/**
* Get platform fee for this membership fee
*/
getPlatformFee(): Money {
return this.amount.calculatePlatformFee();
}
/**
* Get net amount after platform fee
*/
getNetAmount(): Money {
return this.amount.calculateNetAmount();
}
get props(): MembershipFeeProps {
return {
type: this.type,
amount: this.amount,
};
}
/**
* Check if this is a recurring fee
*/
isRecurring(): boolean {
return this.type === 'monthly';
}
equals(other: IValueObject<MembershipFeeProps>): boolean {
const a = this.props;
const b = other.props;
return a.type === b.type && a.amount.equals(b.amount);
}
/**
* Get display name for fee type
*/
getDisplayName(): string {
switch (this.type) {
case 'season':
return 'Season Fee';
case 'monthly':
return 'Monthly Subscription';
case 'per_race':
return 'Per-Race Fee';
}
}
}

View File

@@ -0,0 +1,115 @@
/**
* Value Object: Money
* Represents a monetary amount with currency and platform fee calculation
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type Currency = 'USD' | 'EUR' | 'GBP';
export interface MoneyProps {
amount: number;
currency: Currency;
}
export class Money implements IValueObject<MoneyProps> {
private static readonly PLATFORM_FEE_PERCENTAGE = 0.10;
readonly amount: number;
readonly currency: Currency;
private constructor(amount: number, currency: Currency) {
this.amount = amount;
this.currency = currency;
}
static create(amount: number, currency: Currency = 'USD'): Money {
if (amount < 0) {
throw new RacingDomainValidationError('Money amount cannot be negative');
}
if (!Number.isFinite(amount)) {
throw new RacingDomainValidationError('Money amount must be a finite number');
}
return new Money(amount, currency);
}
/**
* Calculate platform fee (10%)
*/
calculatePlatformFee(): Money {
const feeAmount = this.amount * Money.PLATFORM_FEE_PERCENTAGE;
return new Money(feeAmount, this.currency);
}
/**
* Calculate net amount after platform fee
*/
calculateNetAmount(): Money {
const platformFee = this.calculatePlatformFee();
return new Money(this.amount - platformFee.amount, this.currency);
}
/**
* Add two money amounts
*/
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot add money with different currencies');
}
return new Money(this.amount + other.amount, this.currency);
}
/**
* Subtract two money amounts
*/
subtract(other: Money): Money {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot subtract money with different currencies');
}
const result = this.amount - other.amount;
if (result < 0) {
throw new RacingDomainValidationError('Subtraction would result in negative amount');
}
return new Money(result, this.currency);
}
/**
* Check if this money is greater than another
*/
isGreaterThan(other: Money): boolean {
if (this.currency !== other.currency) {
throw new RacingDomainValidationError('Cannot compare money with different currencies');
}
return this.amount > other.amount;
}
get props(): MoneyProps {
return {
amount: this.amount,
currency: this.currency,
};
}
/**
* Check if this money equals another
*/
equals(other: IValueObject<MoneyProps>): boolean {
const a = this.props;
const b = other.props;
return a.amount === b.amount && a.currency === b.currency;
}
/**
* Format money for display
*/
format(): string {
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: this.currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
return formatter.format(this.amount);
}
}

View File

@@ -0,0 +1,40 @@
import type { Weekday } from '../types/Weekday';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface MonthlyRecurrencePatternProps {
ordinal: 1 | 2 | 3 | 4;
weekday: Weekday;
}
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
constructor(props: MonthlyRecurrencePatternProps);
constructor(
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
weekday?: Weekday,
) {
if (typeof ordinalOrProps === 'object') {
this.ordinal = ordinalOrProps.ordinal;
this.weekday = ordinalOrProps.weekday;
} else {
this.ordinal = ordinalOrProps;
this.weekday = weekday as Weekday;
}
}
get props(): MonthlyRecurrencePatternProps {
return {
ordinal: this.ordinal,
weekday: this.weekday,
};
}
equals(other: IValueObject<MonthlyRecurrencePatternProps>): boolean {
const a = this.props;
const b = other.props;
return a.ordinal === b.ordinal && a.weekday === b.weekday;
}
}

View File

@@ -0,0 +1,46 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PointsTableProps {
pointsByPosition: Map<number, number>;
}
export class PointsTable implements IValueObject<PointsTableProps> {
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;
}
get props(): PointsTableProps {
return {
pointsByPosition: new Map(this.pointsByPosition),
};
}
equals(other: IValueObject<PointsTableProps>): boolean {
const a = this.props.pointsByPosition;
const b = other.props.pointsByPosition;
if (a.size !== b.size) return false;
for (const [key, value] of a.entries()) {
if (b.get(key) !== value) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,239 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Incident types that can occur during a race
*/
export type IncidentType =
| 'track_limits' // Driver went off track and gained advantage
| 'contact' // Physical contact with another car
| 'unsafe_rejoin' // Unsafe rejoining of the track
| 'aggressive_driving' // Aggressive defensive or overtaking maneuvers
| 'false_start' // Started before green flag
| 'collision' // Major collision involving multiple cars
| 'spin' // Driver spun out
| 'mechanical' // Mechanical failure (not driver error)
| 'other'; // Other incident types
/**
* Individual incident record
*/
export interface IncidentRecord {
type: IncidentType;
lap: number;
description?: string;
penaltyPoints?: number; // Points deducted for this incident
}
/**
* Value Object: RaceIncidents
*
* Encapsulates all incidents that occurred during a driver's race.
* Provides methods to calculate total penalty points and incident severity.
*/
export class RaceIncidents implements IValueObject<IncidentRecord[]> {
private readonly incidents: IncidentRecord[];
constructor(incidents: IncidentRecord[] = []) {
this.incidents = [...incidents];
}
get props(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Add a new incident
*/
addIncident(incident: IncidentRecord): RaceIncidents {
return new RaceIncidents([...this.incidents, incident]);
}
/**
* Get all incidents
*/
getAllIncidents(): IncidentRecord[] {
return [...this.incidents];
}
/**
* Get total number of incidents
*/
getTotalCount(): number {
return this.incidents.length;
}
/**
* Get total penalty points from all incidents
*/
getTotalPenaltyPoints(): number {
return this.incidents.reduce((total, incident) => total + (incident.penaltyPoints || 0), 0);
}
/**
* Get incidents by type
*/
getIncidentsByType(type: IncidentType): IncidentRecord[] {
return this.incidents.filter(incident => incident.type === type);
}
/**
* Check if driver had any incidents
*/
hasIncidents(): boolean {
return this.incidents.length > 0;
}
/**
* Check if driver had a clean race (no incidents)
*/
isClean(): boolean {
return this.incidents.length === 0;
}
/**
* Get incident severity score (0-100, higher = more severe)
*/
getSeverityScore(): number {
if (this.incidents.length === 0) return 0;
const severityWeights: Record<IncidentType, number> = {
track_limits: 10,
contact: 20,
unsafe_rejoin: 25,
aggressive_driving: 15,
false_start: 30,
collision: 40,
spin: 35,
mechanical: 5, // Lower weight as it's not driver error
other: 15,
};
const totalSeverity = this.incidents.reduce((total, incident) => {
return total + severityWeights[incident.type];
}, 0);
// Normalize to 0-100 scale (cap at 100 for very incident-heavy races)
return Math.min(100, totalSeverity);
}
/**
* Get human-readable incident summary
*/
getSummary(): string {
if (this.incidents.length === 0) {
return 'Clean race';
}
const typeCounts = this.incidents.reduce((counts, incident) => {
counts[incident.type] = (counts[incident.type] || 0) + 1;
return counts;
}, {} as Record<IncidentType, number>);
const summaryParts = Object.entries(typeCounts).map(([type, count]) => {
const typeLabel = this.getIncidentTypeLabel(type as IncidentType);
return count > 1 ? `${count}x ${typeLabel}` : typeLabel;
});
return summaryParts.join(', ');
}
/**
* Get human-readable label for incident type
*/
private getIncidentTypeLabel(type: IncidentType): string {
const labels: Record<IncidentType, string> = {
track_limits: 'Track Limits',
contact: 'Contact',
unsafe_rejoin: 'Unsafe Rejoin',
aggressive_driving: 'Aggressive Driving',
false_start: 'False Start',
collision: 'Collision',
spin: 'Spin',
mechanical: 'Mechanical',
other: 'Other',
};
return labels[type];
}
equals(other: IValueObject<IncidentRecord[]>): boolean {
const otherIncidents = other.props;
if (this.incidents.length !== otherIncidents.length) {
return false;
}
// Sort both arrays and compare
const sortedThis = [...this.incidents].sort((a, b) => a.lap - b.lap);
const sortedOther = [...otherIncidents].sort((a, b) => a.lap - b.lap);
return sortedThis.every((incident, index) => {
const otherIncident = sortedOther[index];
return incident.type === otherIncident.type &&
incident.lap === otherIncident.lap &&
incident.description === otherIncident.description &&
incident.penaltyPoints === otherIncident.penaltyPoints;
});
}
/**
* Create RaceIncidents from legacy incidents count
*/
static fromLegacyIncidentsCount(count: number): RaceIncidents {
if (count === 0) {
return new RaceIncidents();
}
// Distribute legacy incidents across different types based on probability
const incidents: IncidentRecord[] = [];
for (let i = 0; i < count; i++) {
const type = RaceIncidents.getRandomIncidentType();
incidents.push({
type,
lap: Math.floor(Math.random() * 20) + 1, // Random lap 1-20
penaltyPoints: RaceIncidents.getDefaultPenaltyPoints(type),
});
}
return new RaceIncidents(incidents);
}
/**
* Get random incident type for legacy data conversion
*/
private static getRandomIncidentType(): IncidentType {
const types: IncidentType[] = [
'track_limits', 'contact', 'unsafe_rejoin', 'aggressive_driving',
'collision', 'spin', 'other'
];
const weights = [0.4, 0.25, 0.15, 0.1, 0.05, 0.04, 0.01]; // Probability weights
const random = Math.random();
let cumulativeWeight = 0;
for (let i = 0; i < types.length; i++) {
cumulativeWeight += weights[i];
if (random <= cumulativeWeight) {
return types[i];
}
}
return 'other';
}
/**
* Get default penalty points for incident type
*/
private static getDefaultPenaltyPoints(type: IncidentType): number {
const penalties: Record<IncidentType, number> = {
track_limits: 0, // Usually just a warning
contact: 2,
unsafe_rejoin: 3,
aggressive_driving: 2,
false_start: 5,
collision: 5,
spin: 0, // Usually no penalty if no contact
mechanical: 0,
other: 2,
};
return penalties[type];
}
}

View File

@@ -0,0 +1,55 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface RaceTimeOfDayProps {
hour: number;
minute: number;
}
export class RaceTimeOfDay implements IValueObject<RaceTimeOfDayProps> {
readonly hour: number;
readonly minute: number;
constructor(hour: number, minute: number) {
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RacingDomainValidationError(`RaceTimeOfDay hour must be between 0 and 23, got ${hour}`);
}
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
throw new RacingDomainValidationError(`RaceTimeOfDay minute must be between 0 and 59, got ${minute}`);
}
this.hour = hour;
this.minute = minute;
}
static fromString(value: string): RaceTimeOfDay {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) {
throw new RacingDomainValidationError(`RaceTimeOfDay string must be in HH:MM 24h format, got "${value}"`);
}
const hour = Number(match[1]);
const minute = Number(match[2]);
return new RaceTimeOfDay(hour, minute);
}
get props(): RaceTimeOfDayProps {
return {
hour: this.hour,
minute: this.minute,
};
}
toString(): string {
const hh = this.hour.toString().padStart(2, '0');
const mm = this.minute.toString().padStart(2, '0');
return `${hh}:${mm}`;
}
equals(other: IValueObject<RaceTimeOfDayProps>): boolean {
const a = this.props;
const b = other.props;
return a.hour === b.hour && a.minute === b.minute;
}
}

View File

@@ -0,0 +1,59 @@
import { WeekdaySet } from './WeekdaySet';
import { MonthlyRecurrencePattern } from './MonthlyRecurrencePattern';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type WeeklyRecurrenceStrategy = {
kind: 'weekly';
weekdays: WeekdaySet;
};
export type EveryNWeeksRecurrenceStrategy = {
kind: 'everyNWeeks';
weekdays: WeekdaySet;
intervalWeeks: number;
};
export type MonthlyNthWeekdayRecurrenceStrategy = {
kind: 'monthlyNthWeekday';
monthlyPattern: MonthlyRecurrencePattern;
};
export type RecurrenceStrategy =
| WeeklyRecurrenceStrategy
| EveryNWeeksRecurrenceStrategy
| MonthlyNthWeekdayRecurrenceStrategy;
export class RecurrenceStrategyFactory {
static weekly(weekdays: WeekdaySet): RecurrenceStrategy {
if (weekdays.getAll().length === 0) {
throw new RacingDomainValidationError('weekdays are required for weekly recurrence');
}
return {
kind: 'weekly',
weekdays,
};
}
static everyNWeeks(intervalWeeks: number, weekdays: WeekdaySet): RecurrenceStrategy {
if (!Number.isInteger(intervalWeeks) || intervalWeeks <= 0) {
throw new RacingDomainValidationError('intervalWeeks must be a positive integer');
}
if (weekdays.getAll().length === 0) {
throw new RacingDomainValidationError('weekdays are required for everyNWeeks recurrence');
}
return {
kind: 'everyNWeeks',
weekdays,
intervalWeeks,
};
}
static monthlyNthWeekday(pattern: MonthlyRecurrencePattern): RecurrenceStrategy {
return {
kind: 'monthlyNthWeekday',
monthlyPattern: pattern,
};
}
}

View File

@@ -0,0 +1,47 @@
import { LeagueTimezone } from './LeagueTimezone';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface ScheduledRaceSlotProps {
roundNumber: number;
scheduledAt: Date;
timezone: LeagueTimezone;
}
export class ScheduledRaceSlot implements IValueObject<ScheduledRaceSlotProps> {
readonly roundNumber: number;
readonly scheduledAt: Date;
readonly timezone: LeagueTimezone;
constructor(params: { roundNumber: number; scheduledAt: Date; timezone: LeagueTimezone }) {
if (!Number.isInteger(params.roundNumber) || params.roundNumber <= 0) {
throw new RacingDomainValidationError('ScheduledRaceSlot.roundNumber must be a positive integer');
}
if (!(params.scheduledAt instanceof Date) || Number.isNaN(params.scheduledAt.getTime())) {
throw new RacingDomainValidationError('ScheduledRaceSlot.scheduledAt must be a valid Date');
}
this.roundNumber = params.roundNumber;
this.scheduledAt = params.scheduledAt;
this.timezone = params.timezone;
}
get props(): ScheduledRaceSlotProps {
return {
roundNumber: this.roundNumber,
scheduledAt: this.scheduledAt,
timezone: this.timezone,
};
}
equals(other: IValueObject<ScheduledRaceSlotProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.roundNumber === b.roundNumber &&
a.scheduledAt.getTime() === b.scheduledAt.getTime() &&
a.timezone.equals(b.timezone)
);
}
}

View File

@@ -0,0 +1,59 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface SeasonDropPolicyProps {
strategy: SeasonDropStrategy;
/**
* Number of results to consider for strategies that require a count.
* - bestNResults: keep best N
* - dropWorstN: drop worst N
*/
n?: number;
}
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
readonly strategy: SeasonDropStrategy;
readonly n?: number;
constructor(props: SeasonDropPolicyProps) {
if (!props.strategy) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.strategy is required',
);
}
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
);
}
}
if (props.strategy === 'none' && props.n !== undefined) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be undefined when strategy is none',
);
}
this.strategy = props.strategy;
if (props.n !== undefined) {
this.n = props.n;
}
}
get props(): SeasonDropPolicyProps {
return {
strategy: this.strategy,
...(this.n !== undefined ? { n: this.n } : {}),
};
}
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
const a = this.props;
const b = other.props;
return a.strategy === b.strategy && a.n === b.n;
}
}

View File

@@ -0,0 +1,68 @@
import { RaceTimeOfDay } from './RaceTimeOfDay';
import { LeagueTimezone } from './LeagueTimezone';
import type { RecurrenceStrategy } from './RecurrenceStrategy';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SeasonScheduleProps {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}
export class SeasonSchedule implements IValueObject<SeasonScheduleProps> {
readonly startDate: Date;
readonly timeOfDay: RaceTimeOfDay;
readonly timezone: LeagueTimezone;
readonly recurrence: RecurrenceStrategy;
readonly plannedRounds: number;
constructor(params: {
startDate: Date;
timeOfDay: RaceTimeOfDay;
timezone: LeagueTimezone;
recurrence: RecurrenceStrategy;
plannedRounds: number;
}) {
if (!(params.startDate instanceof Date) || Number.isNaN(params.startDate.getTime())) {
throw new RacingDomainValidationError('SeasonSchedule.startDate must be a valid Date');
}
if (!Number.isInteger(params.plannedRounds) || params.plannedRounds <= 0) {
throw new RacingDomainValidationError('SeasonSchedule.plannedRounds must be a positive integer');
}
this.startDate = new Date(
params.startDate.getFullYear(),
params.startDate.getMonth(),
params.startDate.getDate(),
);
this.timeOfDay = params.timeOfDay;
this.timezone = params.timezone;
this.recurrence = params.recurrence;
this.plannedRounds = params.plannedRounds;
}
get props(): SeasonScheduleProps {
return {
startDate: this.startDate,
timeOfDay: this.timeOfDay,
timezone: this.timezone,
recurrence: this.recurrence,
plannedRounds: this.plannedRounds,
};
}
equals(other: IValueObject<SeasonScheduleProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.startDate.getTime() === b.startDate.getTime() &&
a.timeOfDay.equals(b.timeOfDay) &&
a.timezone.equals(b.timezone) &&
a.recurrence.kind === b.recurrence.kind &&
a.plannedRounds === b.plannedRounds
);
}
}

View File

@@ -0,0 +1,66 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SeasonScoringConfig
*
* Represents the scoring configuration owned by a Season.
* It is intentionally lightweight and primarily captures which
* preset (or custom mode) is applied for this Season.
*
* Detailed championship scoring rules are still modeled via
* `LeagueScoringConfig` and related types.
*/
export interface SeasonScoringConfigProps {
/**
* Identifier of the scoring preset applied to this Season.
* Examples:
* - 'sprint-main-driver'
* - 'club-default'
* - 'endurance-main-double'
* - 'custom'
*/
scoringPresetId: string;
/**
* Whether the Season uses custom scoring rather than a pure preset.
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
*/
customScoringEnabled?: boolean;
}
export class SeasonScoringConfig
implements IValueObject<SeasonScoringConfigProps>
{
readonly scoringPresetId: string;
readonly customScoringEnabled: boolean;
constructor(params: SeasonScoringConfigProps) {
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
throw new RacingDomainValidationError(
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
);
}
this.scoringPresetId = params.scoringPresetId.trim();
this.customScoringEnabled = Boolean(params.customScoringEnabled);
}
get props(): SeasonScoringConfigProps {
return {
scoringPresetId: this.scoringPresetId,
...(this.customScoringEnabled
? { customScoringEnabled: this.customScoringEnabled }
: {}),
};
}
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.scoringPresetId === b.scoringPresetId &&
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
);
}
}

View File

@@ -0,0 +1,142 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
import type { StewardingDecisionMode } from '../entities/League';
export interface SeasonStewardingConfigProps {
decisionMode: StewardingDecisionMode;
requiredVotes?: number | undefined;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
}
/**
* Value Object: SeasonStewardingConfig
*
* Encapsulates stewarding configuration owned by a Season.
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
*/
export class SeasonStewardingConfig
implements IValueObject<SeasonStewardingConfigProps>
{
readonly decisionMode: StewardingDecisionMode;
readonly requiredVotes?: number;
readonly requireDefense: boolean;
readonly defenseTimeLimit: number;
readonly voteTimeLimit: number;
readonly protestDeadlineHours: number;
readonly stewardingClosesHours: number;
readonly notifyAccusedOnProtest: boolean;
readonly notifyOnVoteRequired: boolean;
constructor(props: SeasonStewardingConfigProps) {
if (!props.decisionMode) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.decisionMode is required',
);
}
if (
(props.decisionMode === 'steward_vote' ||
props.decisionMode === 'member_vote' ||
props.decisionMode === 'steward_veto' ||
props.decisionMode === 'member_veto') &&
(props.requiredVotes === undefined ||
!Number.isInteger(props.requiredVotes) ||
props.requiredVotes <= 0)
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
);
}
// For non-voting modes, requiredVotes should not be provided
if (props.decisionMode !== 'steward_vote' &&
props.decisionMode !== 'member_vote' &&
props.decisionMode !== 'steward_veto' &&
props.decisionMode !== 'member_veto' &&
props.requiredVotes !== undefined) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes should only be provided for voting/veto modes',
);
}
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
);
}
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.protestDeadlineHours) ||
props.protestDeadlineHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.stewardingClosesHours) ||
props.stewardingClosesHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
);
}
this.decisionMode = props.decisionMode;
if (props.requiredVotes !== undefined) {
this.requiredVotes = props.requiredVotes;
}
this.requireDefense = props.requireDefense;
this.defenseTimeLimit = props.defenseTimeLimit;
this.voteTimeLimit = props.voteTimeLimit;
this.protestDeadlineHours = props.protestDeadlineHours;
this.stewardingClosesHours = props.stewardingClosesHours;
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
}
get props(): SeasonStewardingConfigProps {
return {
decisionMode: this.decisionMode,
...(this.requiredVotes !== undefined
? { requiredVotes: this.requiredVotes }
: {}),
requireDefense: this.requireDefense,
defenseTimeLimit: this.defenseTimeLimit,
voteTimeLimit: this.voteTimeLimit,
protestDeadlineHours: this.protestDeadlineHours,
stewardingClosesHours: this.stewardingClosesHours,
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
notifyOnVoteRequired: this.notifyOnVoteRequired,
};
}
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.decisionMode === b.decisionMode &&
a.requiredVotes === b.requiredVotes &&
a.requireDefense === b.requireDefense &&
a.defenseTimeLimit === b.defenseTimeLimit &&
a.voteTimeLimit === b.voteTimeLimit &&
a.protestDeadlineHours === b.protestDeadlineHours &&
a.stewardingClosesHours === b.stewardingClosesHours &&
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
a.notifyOnVoteRequired === b.notifyOnVoteRequired
);
}
}

View File

@@ -0,0 +1,103 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SessionType
*
* Represents the type of racing session within a race event.
* Immutable value object with domain validation.
*/
export type SessionTypeValue = 'practice' | 'qualifying' | 'q1' | 'q2' | 'q3' | 'sprint' | 'main' | 'timeTrial';
export class SessionType implements IValueObject<SessionTypeValue> {
readonly value: SessionTypeValue;
constructor(value: SessionTypeValue) {
if (!value || !this.isValidSessionType(value)) {
throw new RacingDomainValidationError(`Invalid session type: ${value}`);
}
this.value = value;
}
private isValidSessionType(value: string): value is SessionTypeValue {
const validTypes: SessionTypeValue[] = ['practice', 'qualifying', 'q1', 'q2', 'q3', 'sprint', 'main', 'timeTrial'];
return validTypes.includes(value as SessionTypeValue);
}
get props(): SessionTypeValue {
return this.value;
}
equals(other: IValueObject<SessionTypeValue>): boolean {
return this.value === other.props;
}
/**
* Check if this session type counts for championship points
*/
countsForPoints(): boolean {
return this.value === 'main' || this.value === 'sprint';
}
/**
* Check if this session type determines grid positions
*/
determinesGrid(): boolean {
return this.value === 'qualifying' || this.value.startsWith('q');
}
/**
* Get human-readable display name
*/
getDisplayName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'Practice',
qualifying: 'Qualifying',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'Sprint Race',
main: 'Main Race',
timeTrial: 'Time Trial',
};
return names[this.value];
}
/**
* Get short display name for UI
*/
getShortName(): string {
const names: Record<SessionTypeValue, string> = {
practice: 'P',
qualifying: 'Q',
q1: 'Q1',
q2: 'Q2',
q3: 'Q3',
sprint: 'SPR',
main: 'RACE',
timeTrial: 'TT',
};
return names[this.value];
}
// Static factory methods for common types
static practice(): SessionType {
return new SessionType('practice');
}
static qualifying(): SessionType {
return new SessionType('qualifying');
}
static sprint(): SessionType {
return new SessionType('sprint');
}
static main(): SessionType {
return new SessionType('main');
}
static timeTrial(): SessionType {
return new SessionType('timeTrial');
}
}

View File

@@ -0,0 +1,262 @@
/**
* Value Object: SponsorshipPricing
*
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
*/
import { Money } from './Money';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface SponsorshipSlotConfig {
tier: 'main' | 'secondary';
price: Money;
benefits: string[];
available: boolean;
maxSlots: number; // How many sponsors of this tier can exist (1 for main, 2 for secondary typically)
}
export interface SponsorshipPricingProps {
mainSlot?: SponsorshipSlotConfig | undefined;
secondarySlots?: SponsorshipSlotConfig | undefined;
acceptingApplications: boolean;
customRequirements?: string | undefined;
}
export class SponsorshipPricing implements IValueObject<SponsorshipPricingProps> {
readonly mainSlot: SponsorshipSlotConfig | undefined;
readonly secondarySlots: SponsorshipSlotConfig | undefined;
readonly acceptingApplications: boolean;
readonly customRequirements: string | undefined;
private constructor(props: SponsorshipPricingProps) {
this.mainSlot = props.mainSlot;
this.secondarySlots = props.secondarySlots;
this.acceptingApplications = props.acceptingApplications;
this.customRequirements = props.customRequirements;
}
get props(): SponsorshipPricingProps {
return {
mainSlot: this.mainSlot,
secondarySlots: this.secondarySlots,
acceptingApplications: this.acceptingApplications,
customRequirements: this.customRequirements,
};
}
equals(other: IValueObject<SponsorshipPricingProps>): boolean {
const a = this.props;
const b = other.props;
const mainEqual =
(a.mainSlot === undefined && b.mainSlot === undefined) ||
(a.mainSlot !== undefined &&
b.mainSlot !== undefined &&
a.mainSlot.tier === b.mainSlot.tier &&
a.mainSlot.price.amount === b.mainSlot.price.amount &&
a.mainSlot.price.currency === b.mainSlot.price.currency &&
a.mainSlot.available === b.mainSlot.available &&
a.mainSlot.maxSlots === b.mainSlot.maxSlots &&
a.mainSlot.benefits.length === b.mainSlot.benefits.length &&
a.mainSlot.benefits.every((val, idx) => val === b.mainSlot!.benefits[idx]));
const secondaryEqual =
(a.secondarySlots === undefined && b.secondarySlots === undefined) ||
(a.secondarySlots !== undefined &&
b.secondarySlots !== undefined &&
a.secondarySlots.tier === b.secondarySlots.tier &&
a.secondarySlots.price.amount === b.secondarySlots.price.amount &&
a.secondarySlots.price.currency === b.secondarySlots.price.currency &&
a.secondarySlots.available === b.secondarySlots.available &&
a.secondarySlots.maxSlots === b.secondarySlots.maxSlots &&
a.secondarySlots.benefits.length === b.secondarySlots.benefits.length &&
a.secondarySlots.benefits.every(
(val, idx) => val === b.secondarySlots!.benefits[idx],
));
return (
mainEqual &&
secondaryEqual &&
a.acceptingApplications === b.acceptingApplications &&
a.customRequirements === b.customRequirements
);
}
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
return new SponsorshipPricing({
...(props.mainSlot !== undefined ? { mainSlot: props.mainSlot } : {}),
...(props.secondarySlots !== undefined ? { secondarySlots: props.secondarySlots } : {}),
acceptingApplications: props.acceptingApplications ?? true,
...(props.customRequirements !== undefined ? { customRequirements: props.customRequirements } : {}),
});
}
/**
* Create default pricing for a driver
*/
static defaultDriver(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(200, 'USD'),
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a team
*/
static defaultTeam(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(500, 'USD'),
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Team page logo', 'Minor livery placement'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a race
*/
static defaultRace(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(300, 'USD'),
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a league/season
*/
static defaultLeague(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(800, 'USD'),
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Side logo placement', 'League page listing'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Check if a specific tier is available
*/
isSlotAvailable(tier: 'main' | 'secondary'): boolean {
if (tier === 'main') {
return !!this.mainSlot?.available;
}
return !!this.secondarySlots?.available;
}
/**
* Get price for a specific tier
*/
getPrice(tier: 'main' | 'secondary'): Money | null {
if (tier === 'main') {
return this.mainSlot?.price ?? null;
}
return this.secondarySlots?.price ?? null;
}
/**
* Get benefits for a specific tier
*/
getBenefits(tier: 'main' | 'secondary'): string[] {
if (tier === 'main') {
return this.mainSlot?.benefits ?? [];
}
return this.secondarySlots?.benefits ?? [];
}
/**
* Update main slot pricing
*/
updateMainSlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentMain = this.mainSlot ?? {
tier: 'main' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 1,
};
const base = this.props;
return new SponsorshipPricing({
...base,
mainSlot: {
...currentMain,
...config,
tier: 'main',
},
});
}
/**
* Update secondary slot pricing
*/
updateSecondarySlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentSecondary = this.secondarySlots ?? {
tier: 'secondary' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 2,
};
const base = this.props;
return new SponsorshipPricing({
...base,
secondarySlots: {
...currentSecondary,
...config,
tier: 'secondary',
},
});
}
/**
* Enable/disable accepting applications
*/
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
const base = this.props;
return new SponsorshipPricing({
...base,
acceptingApplications: accepting,
});
}
}

View File

@@ -0,0 +1,44 @@
import type { Weekday } from '../types/Weekday';
import { weekdayToIndex } from '../types/Weekday';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export interface WeekdaySetProps {
days: Weekday[];
}
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
static fromArray(days: Weekday[]): WeekdaySet {
return new WeekdaySet(days);
}
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');
}
const unique = Array.from(new Set(days));
this.days = unique.sort((a, b) => weekdayToIndex(a) - weekdayToIndex(b));
}
get props(): WeekdaySetProps {
return { days: [...this.days] };
}
getAll(): Weekday[] {
return [...this.days];
}
includes(day: Weekday): boolean {
return this.days.includes(day);
}
equals(other: IValueObject<WeekdaySetProps>): boolean {
const a = this.props.days;
const b = other.props.days;
if (a.length !== b.length) return false;
return a.every((day, index) => day === b[index]);
}
}