diff --git a/core/racing/domain/entities/League.ts b/core/racing/domain/entities/League.ts index b92ac51e9..67de52a7f 100644 --- a/core/racing/domain/entities/League.ts +++ b/core/racing/domain/entities/League.ts @@ -12,10 +12,15 @@ import { LeagueDescription } from './LeagueDescription'; import { LeagueOwnerId } from './LeagueOwnerId'; import { LeagueCreatedAt } from './LeagueCreatedAt'; import { LeagueSocialLinks } from './LeagueSocialLinks'; +import { LeagueVisibility, LeagueVisibilityType } from '../value-objects/LeagueVisibility'; +import { ParticipantCount } from '../value-objects/ParticipantCount'; +import { MaxParticipants } from '../value-objects/MaxParticipants'; +import { SessionDuration } from '../value-objects/SessionDuration'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; /** -* Stewarding decision mode for protests -*/ + * Stewarding decision mode for protests + */ export type StewardingDecisionMode = | 'admin_only' // Only admins can decide | 'steward_decides' // Single steward makes decision @@ -78,6 +83,11 @@ export interface LeagueSettings { * Stewarding settings for protest handling */ stewarding?: StewardingSettings; + /** + * League visibility type (ranked/unranked) + * Determines participant requirements and rating impact + */ + visibility?: LeagueVisibilityType; } export class League implements IEntity { @@ -88,6 +98,10 @@ export class League implements IEntity { readonly settings: LeagueSettings; readonly createdAt: LeagueCreatedAt; readonly socialLinks: LeagueSocialLinks | undefined; + + // Domain state for business rule enforcement + private readonly _participantCount: ParticipantCount; + private readonly _visibility: LeagueVisibility; private constructor(props: { id: LeagueId; @@ -97,6 +111,8 @@ export class League implements IEntity { settings: LeagueSettings; createdAt: LeagueCreatedAt; socialLinks?: LeagueSocialLinks; + participantCount: ParticipantCount; + visibility: LeagueVisibility; }) { this.id = props.id; this.name = props.name; @@ -105,10 +121,13 @@ export class League implements IEntity { this.settings = props.settings; this.createdAt = props.createdAt; this.socialLinks = props.socialLinks; + this._participantCount = props.participantCount; + this._visibility = props.visibility; } /** * Factory method to create a new League entity + * Enforces all business rules and invariants */ static create(props: { id: string; @@ -122,13 +141,61 @@ export class League implements IEntity { youtubeUrl?: string; websiteUrl?: string; }; + participantCount?: number; }): League { + // Validate required fields + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('League ID is required'); + } + + if (!props.name || props.name.trim().length === 0) { + throw new RacingDomainValidationError('League name is required'); + } + + if (!props.description || props.description.trim().length === 0) { + throw new RacingDomainValidationError('League description is required'); + } + + if (!props.ownerId || props.ownerId.trim().length === 0) { + throw new RacingDomainValidationError('League owner ID is required'); + } + const id = LeagueId.create(props.id); const name = LeagueName.create(props.name); const description = LeagueDescription.create(props.description); const ownerId = LeagueOwnerId.create(props.ownerId); const createdAt = LeagueCreatedAt.create(props.createdAt ?? new Date()); + // Determine visibility from settings or default to ranked + const visibilityType = props.settings?.visibility ?? 'ranked'; + const visibility = LeagueVisibility.fromString(visibilityType); + + // Validate maxDrivers against visibility constraints + const maxDrivers = props.settings?.maxDrivers ?? 32; + const maxParticipants = MaxParticipants.create(maxDrivers); + const maxValidation = visibility.validateMaxParticipants(maxParticipants.toNumber()); + if (!maxValidation.valid) { + throw new RacingDomainValidationError(maxValidation.error!); + } + + // Validate session duration if provided + if (props.settings?.sessionDuration !== undefined) { + try { + SessionDuration.create(props.settings.sessionDuration); + } catch (error) { + if (error instanceof RacingDomainValidationError) { + throw error; + } + throw new RacingDomainValidationError('Invalid session duration'); + } + } + + // Validate stewarding settings if provided + if (props.settings?.stewarding) { + League.validateStewardingSettings(props.settings.stewarding); + } + + // Create default stewarding settings const defaultStewardingSettings: StewardingSettings = { decisionMode: 'admin_only', requireDefense: false, @@ -140,14 +207,31 @@ export class League implements IEntity { notifyOnVoteRequired: true, }; + // Build final settings with validation const defaultSettings: LeagueSettings = { pointsSystem: 'f1-2024', sessionDuration: 60, qualifyingFormat: 'open', - maxDrivers: 32, + maxDrivers: maxDrivers, + visibility: visibilityType, stewarding: defaultStewardingSettings, }; + const finalSettings = { ...defaultSettings, ...props.settings }; + + // Validate participant count against visibility and max + const participantCount = ParticipantCount.create(props.participantCount ?? 0); + const participantValidation = visibility.validateDriverCount(participantCount.toNumber()); + if (!participantValidation.valid) { + throw new RacingDomainValidationError(participantValidation.error!); + } + + if (!maxParticipants.canAccommodate(participantCount.toNumber())) { + throw new RacingDomainValidationError( + `Participant count (${participantCount.toNumber()}) exceeds league capacity (${maxParticipants.toNumber()})` + ); + } + const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined; return new League({ @@ -155,15 +239,58 @@ export class League implements IEntity { name, description, ownerId, - settings: { ...defaultSettings, ...props.settings }, + settings: finalSettings, createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), + participantCount, + visibility, }); } + /** + * Validate stewarding settings configuration + */ + private static validateStewardingSettings(settings: StewardingSettings): void { + if (!settings.decisionMode) { + throw new RacingDomainValidationError('Stewarding decision mode is required'); + } + + const votingModes = ['steward_vote', 'member_vote', 'steward_veto', 'member_veto']; + if (votingModes.includes(settings.decisionMode)) { + if (!settings.requiredVotes || settings.requiredVotes <= 0) { + throw new RacingDomainValidationError( + 'Stewarding settings with voting modes require a positive requiredVotes value' + ); + } + } + + if (settings.requiredVotes !== undefined && !votingModes.includes(settings.decisionMode)) { + throw new RacingDomainValidationError( + 'requiredVotes should only be provided for voting/veto modes' + ); + } + + // Validate time limits + if (settings.defenseTimeLimit !== undefined && settings.defenseTimeLimit < 0) { + throw new RacingDomainValidationError('Defense time limit must be non-negative'); + } + + if (settings.voteTimeLimit !== undefined && settings.voteTimeLimit <= 0) { + throw new RacingDomainValidationError('Vote time limit must be positive'); + } + + if (settings.protestDeadlineHours !== undefined && settings.protestDeadlineHours <= 0) { + throw new RacingDomainValidationError('Protest deadline must be positive'); + } + + if (settings.stewardingClosesHours !== undefined && settings.stewardingClosesHours <= 0) { + throw new RacingDomainValidationError('Stewarding close time must be positive'); + } + } /** * Create a copy with updated properties + * Validates all business rules on update */ update(props: Partial<{ name: string; @@ -181,14 +308,162 @@ export class League implements IEntity { const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId; const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks; + // If settings are being updated, validate them + let newSettings = props.settings ?? this.settings; + if (props.settings) { + // Validate visibility constraints if visibility is changing + if (props.settings.visibility && props.settings.visibility !== this.settings.visibility) { + const newVisibility = LeagueVisibility.fromString(props.settings.visibility); + const maxDrivers = props.settings.maxDrivers ?? this.settings.maxDrivers ?? 32; + + const maxValidation = newVisibility.validateMaxParticipants(maxDrivers); + if (!maxValidation.valid) { + throw new RacingDomainValidationError(maxValidation.error!); + } + + const participantValidation = newVisibility.validateDriverCount(this._participantCount.toNumber()); + if (!participantValidation.valid) { + throw new RacingDomainValidationError(participantValidation.error!); + } + } + + // Validate session duration if changing + if (props.settings.sessionDuration !== undefined && props.settings.sessionDuration !== this.settings.sessionDuration) { + try { + SessionDuration.create(props.settings.sessionDuration); + } catch (error) { + if (error instanceof RacingDomainValidationError) { + throw error; + } + throw new RacingDomainValidationError('Invalid session duration'); + } + } + + // Validate max drivers if changing + if (props.settings.maxDrivers !== undefined && props.settings.maxDrivers !== this.settings.maxDrivers) { + const visibility = LeagueVisibility.fromString(this.settings.visibility ?? 'ranked'); + const maxValidation = visibility.validateMaxParticipants(props.settings.maxDrivers); + if (!maxValidation.valid) { + throw new RacingDomainValidationError(maxValidation.error!); + } + + if (props.settings.maxDrivers < this._participantCount.toNumber()) { + throw new RacingDomainValidationError( + `Cannot reduce max drivers (${props.settings.maxDrivers}) below current participant count (${this._participantCount.toNumber()})` + ); + } + } + + // Validate stewarding settings if changing + if (props.settings.stewarding) { + League.validateStewardingSettings(props.settings.stewarding); + } + + newSettings = { ...this.settings, ...props.settings }; + } + return new League({ id: this.id, name, description, ownerId, - settings: props.settings ?? this.settings, + settings: newSettings, createdAt: this.createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), + participantCount: this._participantCount, + visibility: this._visibility, }); } + + /** + * Add a participant to the league + * Validates against capacity and visibility constraints + */ + addParticipant(): League { + const newCount = this._participantCount.increment(); + const maxDrivers = this.settings.maxDrivers ?? 32; + + if (newCount.toNumber() > maxDrivers) { + throw new RacingDomainInvariantError( + `Cannot add participant: league capacity (${maxDrivers}) would be exceeded` + ); + } + + const visibility = LeagueVisibility.fromString(this.settings.visibility ?? 'ranked'); + const validation = visibility.validateDriverCount(newCount.toNumber()); + if (!validation.valid) { + throw new RacingDomainInvariantError(validation.error!); + } + + return new League({ + id: this.id, + name: this.name, + description: this.description, + ownerId: this.ownerId, + settings: this.settings, + createdAt: this.createdAt, + ...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}), + participantCount: newCount, + visibility: this._visibility, + }); + } + + /** + * Remove a participant from the league + */ + removeParticipant(): League { + if (this._participantCount.isZero()) { + throw new RacingDomainInvariantError('Cannot remove participant: league has no participants'); + } + + const newCount = this._participantCount.decrement(); + + return new League({ + id: this.id, + name: this.name, + description: this.description, + ownerId: this.ownerId, + settings: this.settings, + createdAt: this.createdAt, + ...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}), + participantCount: newCount, + visibility: this._visibility, + }); + } + + /** + * Get current participant count + */ + getParticipantCount(): number { + return this._participantCount.toNumber(); + } + + /** + * Get league visibility + */ + getVisibility(): LeagueVisibility { + return this._visibility; + } + + /** + * Check if league is full + */ + isFull(): boolean { + const maxDrivers = this.settings.maxDrivers ?? 32; + return this._participantCount.toNumber() >= maxDrivers; + } + + /** + * Check if league meets minimum participant requirements + */ + meetsMinimumRequirements(): boolean { + return this._visibility.meetsMinimumForVisibility(this._participantCount.toNumber()); + } + + /** + * Check if league can accept more participants + */ + canAcceptMore(): boolean { + return !this.isFull(); + } } \ No newline at end of file diff --git a/core/racing/domain/entities/Race.ts b/core/racing/domain/entities/Race.ts index ea9cefeb3..8900ba5e6 100644 --- a/core/racing/domain/entities/Race.ts +++ b/core/racing/domain/entities/Race.ts @@ -4,12 +4,16 @@ * Represents a race/session in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - + import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; import { SessionType } from '../value-objects/SessionType'; -export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled'; - +import { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus'; +import { ParticipantCount } from '../value-objects/ParticipantCount'; +import { MaxParticipants } from '../value-objects/MaxParticipants'; + +export type { RaceStatus, RaceStatusValue } from '../value-objects/RaceStatus'; + export class Race implements IEntity { readonly id: string; readonly leagueId: string; @@ -21,8 +25,8 @@ export class Race implements IEntity { readonly sessionType: SessionType; readonly status: RaceStatus; readonly strengthOfField: number | undefined; - readonly registeredCount: number | undefined; - readonly maxParticipants: number | undefined; + readonly registeredCount: ParticipantCount | undefined; + readonly maxParticipants: MaxParticipants | undefined; private constructor(props: { id: string; @@ -35,8 +39,8 @@ export class Race implements IEntity { sessionType: SessionType; status: RaceStatus; strengthOfField?: number; - registeredCount?: number; - maxParticipants?: number; + registeredCount?: ParticipantCount; + maxParticipants?: MaxParticipants; }) { this.id = props.id; this.leagueId = props.leagueId; @@ -54,6 +58,7 @@ export class Race implements IEntity { /** * Factory method to create a new Race entity + * Enforces all business rules and invariants */ static create(props: { id: string; @@ -64,39 +69,12 @@ export class Race implements IEntity { car: string; carId?: string; sessionType?: SessionType; - status?: RaceStatus; + status?: RaceStatusValue; strengthOfField?: number; registeredCount?: number; maxParticipants?: number; }): Race { - this.validate(props); - - return new Race({ - id: props.id, - leagueId: props.leagueId, - scheduledAt: props.scheduledAt, - track: props.track, - ...(props.trackId !== undefined ? { trackId: props.trackId } : {}), - car: props.car, - ...(props.carId !== undefined ? { carId: props.carId } : {}), - sessionType: props.sessionType ?? SessionType.main(), - status: props.status ?? 'scheduled', - ...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}), - ...(props.registeredCount !== undefined ? { registeredCount: props.registeredCount } : {}), - ...(props.maxParticipants !== undefined ? { maxParticipants: props.maxParticipants } : {}), - }); - } - - /** - * Domain validation logic - */ - private static validate(props: { - id: string; - leagueId: string; - scheduledAt: Date; - track: string; - car: string; - }): void { + // Validate required fields if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Race ID is required'); } @@ -105,7 +83,7 @@ export class Race implements IEntity { throw new RacingDomainValidationError('League ID is required'); } - if (!props.scheduledAt || !(props.scheduledAt instanceof Date)) { + if (!props.scheduledAt || !(props.scheduledAt instanceof Date) || isNaN(props.scheduledAt.getTime())) { throw new RacingDomainValidationError('Valid scheduled date is required'); } @@ -116,198 +94,250 @@ export class Race implements IEntity { if (!props.car || props.car.trim().length === 0) { throw new RacingDomainValidationError('Car is required'); } + + // Validate status + const status = RaceStatus.create(props.status ?? 'scheduled'); + + // Validate participant counts + let registeredCount: ParticipantCount | undefined; + let maxParticipants: MaxParticipants | undefined; + + if (props.registeredCount !== undefined) { + registeredCount = ParticipantCount.create(props.registeredCount); + } + + if (props.maxParticipants !== undefined) { + maxParticipants = MaxParticipants.create(props.maxParticipants); + + // Validate that registered count doesn't exceed max + if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) { + throw new RacingDomainValidationError( + `Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})` + ); + } + } + + // Validate strength of field if provided + if (props.strengthOfField !== undefined) { + if (props.strengthOfField < 0 || props.strengthOfField > 100) { + throw new RacingDomainValidationError('Strength of field must be between 0 and 100'); + } + } + + // Validate scheduled time is not in the past for new races + if (status.isScheduled() && props.scheduledAt < new Date()) { + throw new RacingDomainValidationError('Scheduled time cannot be in the past'); + } + + return new Race({ + id: props.id, + leagueId: props.leagueId, + scheduledAt: props.scheduledAt, + track: props.track, + ...(props.trackId !== undefined ? { trackId: props.trackId } : {}), + car: props.car, + ...(props.carId !== undefined ? { carId: props.carId } : {}), + sessionType: props.sessionType ?? SessionType.main(), + status, + ...(props.strengthOfField !== undefined ? { strengthOfField: props.strengthOfField } : {}), + ...(registeredCount !== undefined ? { registeredCount } : {}), + ...(maxParticipants !== undefined ? { maxParticipants } : {}), + }); } /** * Start the race (move from scheduled to running) */ start(): Race { - if (this.status !== 'scheduled') { - throw new RacingDomainInvariantError('Only scheduled races can be started'); + const transition = this.status.canTransitionTo('running'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } - const base = { + return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, - status: 'running' as RaceStatus, - }; - - const withTrackId = - this.trackId !== undefined ? { ...base, trackId: this.trackId } : base; - const withCarId = - this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId; - const withSof = - this.strengthOfField !== undefined - ? { ...withCarId, strengthOfField: this.strengthOfField } - : withCarId; - const withRegistered = - this.registeredCount !== undefined - ? { ...withSof, registeredCount: this.registeredCount } - : withSof; - const props = - this.maxParticipants !== undefined - ? { ...withRegistered, maxParticipants: this.maxParticipants } - : withRegistered; - - return Race.create(props); + status: 'running', + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); } /** * Mark race as completed */ complete(): Race { - if (this.status === 'completed') { - throw new RacingDomainInvariantError('Race is already completed'); + const transition = this.status.canTransitionTo('completed'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } - if (this.status === 'cancelled') { - throw new RacingDomainInvariantError('Cannot complete a cancelled race'); - } - - const base = { + return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, - status: 'completed' as RaceStatus, - }; - - const withTrackId = - this.trackId !== undefined ? { ...base, trackId: this.trackId } : base; - const withCarId = - this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId; - const withSof = - this.strengthOfField !== undefined - ? { ...withCarId, strengthOfField: this.strengthOfField } - : withCarId; - const withRegistered = - this.registeredCount !== undefined - ? { ...withSof, registeredCount: this.registeredCount } - : withSof; - const props = - this.maxParticipants !== undefined - ? { ...withRegistered, maxParticipants: this.maxParticipants } - : withRegistered; - - return Race.create(props); + status: 'completed', + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); } /** * Cancel the race */ cancel(): Race { - if (this.status === 'completed') { - throw new RacingDomainInvariantError('Cannot cancel a completed race'); + const transition = this.status.canTransitionTo('cancelled'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } - if (this.status === 'cancelled') { - throw new RacingDomainInvariantError('Race is already cancelled'); - } - - const base = { + return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, - status: 'cancelled' as RaceStatus, - }; - - const withTrackId = - this.trackId !== undefined ? { ...base, trackId: this.trackId } : base; - const withCarId = - this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId; - const withSof = - this.strengthOfField !== undefined - ? { ...withCarId, strengthOfField: this.strengthOfField } - : withCarId; - const withRegistered = - this.registeredCount !== undefined - ? { ...withSof, registeredCount: this.registeredCount } - : withSof; - const props = - this.maxParticipants !== undefined - ? { ...withRegistered, maxParticipants: this.maxParticipants } - : withRegistered; - - return Race.create(props); + status: 'cancelled', + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); } /** * Re-open a previously completed or cancelled race */ reopen(): Race { - if (this.status === 'scheduled') { - throw new RacingDomainInvariantError('Race is already scheduled'); + const transition = this.status.canTransitionTo('scheduled'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } - if (this.status === 'running') { - throw new RacingDomainInvariantError('Cannot re-open a running race'); - } - - const base = { + return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, - status: 'scheduled' as RaceStatus, - }; - - const withTrackId = - this.trackId !== undefined ? { ...base, trackId: this.trackId } : base; - const withCarId = - this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId; - const withSof = - this.strengthOfField !== undefined - ? { ...withCarId, strengthOfField: this.strengthOfField } - : withCarId; - const withRegistered = - this.registeredCount !== undefined - ? { ...withSof, registeredCount: this.registeredCount } - : withSof; - const props = - this.maxParticipants !== undefined - ? { ...withRegistered, maxParticipants: this.maxParticipants } - : withRegistered; - - return Race.create(props); + status: 'scheduled', + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + ...(this.registeredCount !== undefined ? { registeredCount: this.registeredCount.toNumber() } : {}), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); } /** * Update SOF and participant count */ updateField(strengthOfField: number, registeredCount: number): Race { - const base = { + // Validate strength of field + if (strengthOfField < 0 || strengthOfField > 100) { + throw new RacingDomainValidationError('Strength of field must be between 0 and 100'); + } + + // Validate registered count against max participants + const newRegisteredCount = ParticipantCount.create(registeredCount); + if (this.maxParticipants && !this.maxParticipants.canAccommodate(newRegisteredCount.toNumber())) { + throw new RacingDomainValidationError( + `Registered count (${newRegisteredCount.toNumber()}) exceeds max participants (${this.maxParticipants.toNumber()})` + ); + } + + return Race.create({ id: this.id, leagueId: this.leagueId, scheduledAt: this.scheduledAt, track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), sessionType: this.sessionType, - status: this.status, + status: this.status.toString(), strengthOfField, - registeredCount, - }; + registeredCount: newRegisteredCount.toNumber(), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); + } - const withTrackId = - this.trackId !== undefined ? { ...base, trackId: this.trackId } : base; - const withCarId = - this.carId !== undefined ? { ...withTrackId, carId: this.carId } : withTrackId; - const props = - this.maxParticipants !== undefined - ? { ...withCarId, maxParticipants: this.maxParticipants } - : withCarId; + /** + * Add a participant to the race + */ + addParticipant(): Race { + if (!this.registeredCount) { + throw new RacingDomainInvariantError('Race must have a registered count initialized'); + } - return Race.create(props); + const newCount = this.registeredCount.increment(); + + if (this.maxParticipants && !this.maxParticipants.canAccommodate(newCount.toNumber())) { + throw new RacingDomainInvariantError( + `Cannot add participant: race capacity (${this.maxParticipants.toNumber()}) would be exceeded` + ); + } + + return Race.create({ + id: this.id, + leagueId: this.leagueId, + scheduledAt: this.scheduledAt, + track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), + car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), + sessionType: this.sessionType, + status: this.status.toString(), + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + registeredCount: newCount.toNumber(), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); + } + + /** + * Remove a participant from the race + */ + removeParticipant(): Race { + if (!this.registeredCount) { + throw new RacingDomainInvariantError('Race must have a registered count initialized'); + } + + if (this.registeredCount.isZero()) { + throw new RacingDomainInvariantError('Cannot remove participant: race has no participants'); + } + + const newCount = this.registeredCount.decrement(); + + return Race.create({ + id: this.id, + leagueId: this.leagueId, + scheduledAt: this.scheduledAt, + track: this.track, + ...(this.trackId !== undefined ? { trackId: this.trackId } : {}), + car: this.car, + ...(this.carId !== undefined ? { carId: this.carId } : {}), + sessionType: this.sessionType, + status: this.status.toString(), + ...(this.strengthOfField !== undefined ? { strengthOfField: this.strengthOfField } : {}), + registeredCount: newCount.toNumber(), + ...(this.maxParticipants !== undefined ? { maxParticipants: this.maxParticipants.toNumber() } : {}), + }); } /** @@ -321,13 +351,35 @@ export class Race implements IEntity { * Check if race is upcoming */ isUpcoming(): boolean { - return this.status === 'scheduled' && !this.isPast(); + return this.status.isScheduled() && !this.isPast(); } /** * Check if race is live/running */ isLive(): boolean { - return this.status === 'running'; + return this.status.isRunning(); + } + + /** + * Check if race can accept more participants + */ + canAcceptMore(): boolean { + if (!this.maxParticipants || !this.registeredCount) return false; + return this.maxParticipants.canAccommodate(this.registeredCount.toNumber() + 1); + } + + /** + * Get current registered count + */ + getRegisteredCount(): number { + return this.registeredCount ? this.registeredCount.toNumber() : 0; + } + + /** + * Get max participants + */ + getMaxParticipants(): number | undefined { + return this.maxParticipants ? this.maxParticipants.toNumber() : undefined; } } \ No newline at end of file diff --git a/core/racing/domain/entities/season/Season.ts b/core/racing/domain/entities/season/Season.ts index ab6d02987..561d3d387 100644 --- a/core/racing/domain/entities/season/Season.ts +++ b/core/racing/domain/entities/season/Season.ts @@ -1,10 +1,3 @@ -export type SeasonStatus = - | 'planned' - | 'active' - | 'completed' - | 'archived' - | 'cancelled'; - import { RacingDomainInvariantError, RacingDomainValidationError, @@ -14,6 +7,9 @@ import type { SeasonSchedule } from '../../value-objects/SeasonSchedule'; import type { SeasonScoringConfig } from '../../value-objects/SeasonScoringConfig'; import type { SeasonDropPolicy } from '../../value-objects/SeasonDropPolicy'; import type { SeasonStewardingConfig } from '../../value-objects/SeasonStewardingConfig'; +import { SeasonStatus, SeasonStatusValue } from '../../value-objects/SeasonStatus'; +import { ParticipantCount } from '../../value-objects/ParticipantCount'; +import { MaxParticipants } from '../../value-objects/MaxParticipants'; export class Season implements IEntity { readonly id: string; @@ -31,6 +27,9 @@ export class Season implements IEntity { readonly stewardingConfig: SeasonStewardingConfig | undefined; readonly maxDrivers: number | undefined; + // Domain state for business rule enforcement + private readonly _participantCount: ParticipantCount; + private constructor(props: { id: string; leagueId: string; @@ -46,6 +45,7 @@ export class Season implements IEntity { dropPolicy?: SeasonDropPolicy; stewardingConfig?: SeasonStewardingConfig; maxDrivers?: number; + participantCount: ParticipantCount; }) { this.id = props.id; this.leagueId = props.leagueId; @@ -61,6 +61,7 @@ export class Season implements IEntity { this.dropPolicy = props.dropPolicy; this.stewardingConfig = props.stewardingConfig; this.maxDrivers = props.maxDrivers; + this._participantCount = props.participantCount; } static create(props: { @@ -70,7 +71,7 @@ export class Season implements IEntity { name: string; year?: number; order?: number; - status?: SeasonStatus; + status?: SeasonStatusValue; startDate?: Date; endDate?: Date | undefined; schedule?: SeasonSchedule; @@ -78,7 +79,9 @@ export class Season implements IEntity { dropPolicy?: SeasonDropPolicy; stewardingConfig?: SeasonStewardingConfig; maxDrivers?: number; + participantCount?: number; }): Season { + // Validate required fields if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Season ID is required'); } @@ -95,7 +98,58 @@ export class Season implements IEntity { throw new RacingDomainValidationError('Season name is required'); } - const status: SeasonStatus = props.status ?? 'planned'; + // Validate maxDrivers if provided + if (props.maxDrivers !== undefined) { + if (props.maxDrivers <= 0) { + throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided'); + } + if (props.maxDrivers > 100) { + throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100'); + } + } + + // Validate participant count + const participantCount = ParticipantCount.create(props.participantCount ?? 0); + if (props.maxDrivers !== undefined) { + const maxParticipants = MaxParticipants.create(props.maxDrivers); + if (!maxParticipants.canAccommodate(participantCount.toNumber())) { + throw new RacingDomainValidationError( + `Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})` + ); + } + } + + // Validate status transitions if status is provided + const status = SeasonStatus.create(props.status ?? 'planned'); + + // Validate schedule if provided + if (props.schedule) { + // Ensure schedule dates align with season dates + if (props.startDate && props.schedule.startDate < props.startDate) { + throw new RacingDomainValidationError('Schedule start date cannot be before season start date'); + } + } + + // Validate scoring config if provided + if (props.scoringConfig) { + if (!props.scoringConfig.scoringPresetId || props.scoringConfig.scoringPresetId.trim().length === 0) { + throw new RacingDomainValidationError('Scoring preset ID is required'); + } + } + + // Validate drop policy if provided + if (props.dropPolicy) { + if (props.dropPolicy.strategy === 'bestNResults' || props.dropPolicy.strategy === 'dropWorstN') { + if (!props.dropPolicy.n || props.dropPolicy.n <= 0) { + throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy'); + } + } + } + + // Validate stewarding config if provided + if (props.stewardingConfig) { + Season.validateStewardingConfig(props.stewardingConfig); + } return new Season({ id: props.id, @@ -108,55 +162,110 @@ export class Season implements IEntity { ...(props.startDate !== undefined ? { startDate: props.startDate } : {}), ...(props.endDate !== undefined ? { endDate: props.endDate } : {}), ...(props.schedule !== undefined ? { schedule: props.schedule } : {}), - ...(props.scoringConfig !== undefined - ? { scoringConfig: props.scoringConfig } - : {}), + ...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}), ...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}), - ...(props.stewardingConfig !== undefined - ? { stewardingConfig: props.stewardingConfig } - : {}), + ...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}), ...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}), + participantCount, }); } + /** + * Validate stewarding configuration + */ + private static validateStewardingConfig(config: SeasonStewardingConfig): void { + if (!config.decisionMode) { + throw new RacingDomainValidationError('Stewarding decision mode is required'); + } + + const votingModes = ['steward_vote', 'member_vote', 'steward_veto', 'member_veto']; + if (votingModes.includes(config.decisionMode)) { + if (!config.requiredVotes || config.requiredVotes <= 0) { + throw new RacingDomainValidationError( + 'Stewarding settings with voting modes require a positive requiredVotes value' + ); + } + } + + if (config.requiredVotes !== undefined && !votingModes.includes(config.decisionMode)) { + throw new RacingDomainValidationError( + 'requiredVotes should only be provided for voting/veto modes' + ); + } + + // Validate time limits + if (config.defenseTimeLimit !== undefined && config.defenseTimeLimit < 0) { + throw new RacingDomainValidationError('Defense time limit must be non-negative'); + } + + if (config.voteTimeLimit !== undefined && config.voteTimeLimit <= 0) { + throw new RacingDomainValidationError('Vote time limit must be positive'); + } + + if (config.protestDeadlineHours !== undefined && config.protestDeadlineHours <= 0) { + throw new RacingDomainValidationError('Protest deadline must be positive'); + } + + if (config.stewardingClosesHours !== undefined && config.stewardingClosesHours <= 0) { + throw new RacingDomainValidationError('Stewarding close time must be positive'); + } + } + /** * Domain rule: Wallet withdrawals are only allowed when season is completed */ canWithdrawFromWallet(): boolean { - return this.status === 'completed'; + return this.status.isCompleted(); } /** * Check if season is active */ isActive(): boolean { - return this.status === 'active'; + return this.status.isActive(); } /** * Check if season is completed */ isCompleted(): boolean { - return this.status === 'completed'; + return this.status.isCompleted(); } /** * Check if season is planned (not yet active) */ isPlanned(): boolean { - return this.status === 'planned'; + return this.status.isPlanned(); + } + + /** + * Check if season can accept more participants + */ + canAcceptMore(): boolean { + if (this.maxDrivers === undefined) return true; + return this._participantCount.toNumber() < this.maxDrivers; + } + + /** + * Get current participant count + */ + getParticipantCount(): number { + return this._participantCount.toNumber(); } /** * Activate the season from planned state. */ activate(): Season { - if (this.status !== 'planned') { - throw new RacingDomainInvariantError( - 'Only planned seasons can be activated', - ); + const transition = this.status.canTransitionTo('active'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } + // Ensure start date is set + const startDate = this.startDate ?? new Date(); + return Season.create({ id: this.id, leagueId: this.leagueId, @@ -165,19 +274,14 @@ export class Season implements IEntity { ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), status: 'active', - ...(this.startDate !== undefined ? { startDate: this.startDate } : { - startDate: new Date(), - }), + startDate, ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), }); } @@ -185,12 +289,14 @@ export class Season implements IEntity { * Mark the season as completed. */ complete(): Season { - if (this.status !== 'active') { - throw new RacingDomainInvariantError( - 'Only active seasons can be completed', - ); + const transition = this.status.canTransitionTo('completed'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } + // Ensure end date is set + const endDate = this.endDate ?? new Date(); + return Season.create({ id: this.id, leagueId: this.leagueId, @@ -200,18 +306,13 @@ export class Season implements IEntity { ...(this.order !== undefined ? { order: this.order } : {}), status: 'completed', ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : { - endDate: new Date(), - }), + endDate, ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), }); } @@ -219,10 +320,9 @@ export class Season implements IEntity { * Archive a completed season. */ archive(): Season { - if (!this.isCompleted()) { - throw new RacingDomainInvariantError( - 'Only completed seasons can be archived', - ); + const transition = this.status.canTransitionTo('archived'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } return Season.create({ @@ -236,14 +336,11 @@ export class Season implements IEntity { ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), }); } @@ -251,16 +348,19 @@ export class Season implements IEntity { * Cancel a planned or active season. */ cancel(): Season { - if (this.status === 'completed' || this.status === 'archived') { - throw new RacingDomainInvariantError( - 'Cannot cancel a completed or archived season', - ); + const transition = this.status.canTransitionTo('cancelled'); + if (!transition.valid) { + throw new RacingDomainInvariantError(transition.error!); } - if (this.status === 'cancelled') { + // If already cancelled, return this + if (this.status.isCancelled()) { return this; } + // Ensure end date is set + const endDate = this.endDate ?? new Date(); + return Season.create({ id: this.id, leagueId: this.leagueId, @@ -270,18 +370,13 @@ export class Season implements IEntity { ...(this.order !== undefined ? { order: this.order } : {}), status: 'cancelled', ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : { - endDate: new Date(), - }), + endDate, ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), }); } @@ -289,110 +384,9 @@ export class Season implements IEntity { * Update schedule while keeping other properties intact. */ withSchedule(schedule: SeasonSchedule): Season { - return Season.create({ - id: this.id, - leagueId: this.leagueId, - gameId: this.gameId, - name: this.name, - ...(this.year !== undefined ? { year: this.year } : {}), - ...(this.order !== undefined ? { order: this.order } : {}), - status: this.status, - ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), - schedule, - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), - ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), - ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), - }); - } - - /** - * Update scoring configuration for the season. - */ - withScoringConfig(scoringConfig: SeasonScoringConfig): Season { - return Season.create({ - id: this.id, - leagueId: this.leagueId, - gameId: this.gameId, - name: this.name, - ...(this.year !== undefined ? { year: this.year } : {}), - ...(this.order !== undefined ? { order: this.order } : {}), - status: this.status, - ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), - ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - scoringConfig, - ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), - ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), - }); - } - - /** - * Update drop policy for the season. - */ - withDropPolicy(dropPolicy: SeasonDropPolicy): Season { - return Season.create({ - id: this.id, - leagueId: this.leagueId, - gameId: this.gameId, - name: this.name, - ...(this.year !== undefined ? { year: this.year } : {}), - ...(this.order !== undefined ? { order: this.order } : {}), - status: this.status, - ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), - ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), - dropPolicy, - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), - ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), - }); - } - - /** - * Update stewarding configuration for the season. - */ - withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season { - return Season.create({ - id: this.id, - leagueId: this.leagueId, - gameId: this.gameId, - name: this.name, - ...(this.year !== undefined ? { year: this.year } : {}), - ...(this.order !== undefined ? { order: this.order } : {}), - status: this.status, - ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), - ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), - ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), - ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - stewardingConfig, - ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), - }); - } - - /** - * Update max driver capacity for the season. - */ - withMaxDrivers(maxDrivers: number | undefined): Season { - if (maxDrivers !== undefined && maxDrivers <= 0) { - throw new RacingDomainValidationError( - 'Season maxDrivers must be greater than 0 when provided', - ); + // Validate schedule against season dates + if (this.startDate && schedule.startDate < this.startDate) { + throw new RacingDomainValidationError('Schedule start date cannot be before season start date'); } return Season.create({ @@ -402,18 +396,194 @@ export class Season implements IEntity { name: this.name, ...(this.year !== undefined ? { year: this.year } : {}), ...(this.order !== undefined ? { order: this.order } : {}), - status: this.status, + status: this.status.toString(), + ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), + ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), + schedule, + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), + ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), + ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), + }); + } + + /** + * Update scoring configuration for the season. + */ + withScoringConfig(scoringConfig: SeasonScoringConfig): Season { + if (!scoringConfig.scoringPresetId || scoringConfig.scoringPresetId.trim().length === 0) { + throw new RacingDomainValidationError('Scoring preset ID is required'); + } + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + ...(this.year !== undefined ? { year: this.year } : {}), + ...(this.order !== undefined ? { order: this.order } : {}), + status: this.status.toString(), ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), - ...(this.scoringConfig !== undefined - ? { scoringConfig: this.scoringConfig } - : {}), + scoringConfig, ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), - ...(this.stewardingConfig !== undefined - ? { stewardingConfig: this.stewardingConfig } - : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), + ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), + }); + } + + /** + * Update drop policy for the season. + */ + withDropPolicy(dropPolicy: SeasonDropPolicy): Season { + // Validate drop policy + if (dropPolicy.strategy === 'bestNResults' || dropPolicy.strategy === 'dropWorstN') { + if (!dropPolicy.n || dropPolicy.n <= 0) { + throw new RacingDomainValidationError('Drop policy requires positive n value for this strategy'); + } + } + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + ...(this.year !== undefined ? { year: this.year } : {}), + ...(this.order !== undefined ? { order: this.order } : {}), + status: this.status.toString(), + ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), + ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), + ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), + dropPolicy, + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), + ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), + }); + } + + /** + * Update stewarding configuration for the season. + */ + withStewardingConfig(stewardingConfig: SeasonStewardingConfig): Season { + Season.validateStewardingConfig(stewardingConfig); + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + ...(this.year !== undefined ? { year: this.year } : {}), + ...(this.order !== undefined ? { order: this.order } : {}), + status: this.status.toString(), + ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), + ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), + ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), + ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), + stewardingConfig, + ...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), + }); + } + + /** + * Update max driver capacity for the season. + */ + withMaxDrivers(maxDrivers: number | undefined): Season { + if (maxDrivers !== undefined) { + if (maxDrivers <= 0) { + throw new RacingDomainValidationError('Season maxDrivers must be greater than 0 when provided'); + } + if (maxDrivers > 100) { + throw new RacingDomainValidationError('Season maxDrivers cannot exceed 100'); + } + if (maxDrivers < this._participantCount.toNumber()) { + throw new RacingDomainValidationError( + `Cannot reduce max drivers (${maxDrivers}) below current participant count (${this._participantCount.toNumber()})` + ); + } + } + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + ...(this.year !== undefined ? { year: this.year } : {}), + ...(this.order !== undefined ? { order: this.order } : {}), + status: this.status.toString(), + ...(this.startDate !== undefined ? { startDate: this.startDate } : {}), + ...(this.endDate !== undefined ? { endDate: this.endDate } : {}), + ...(this.schedule !== undefined ? { schedule: this.schedule } : {}), + ...(this.scoringConfig !== undefined ? { scoringConfig: this.scoringConfig } : {}), + ...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}), + ...(this.stewardingConfig !== undefined ? { stewardingConfig: this.stewardingConfig } : {}), ...(maxDrivers !== undefined ? { maxDrivers } : {}), + participantCount: this._participantCount.toNumber(), + }); + } + + /** + * Add a participant to the season + */ + addParticipant(): Season { + if (!this.canAcceptMore()) { + throw new RacingDomainInvariantError( + `Season capacity (${this.maxDrivers}) would be exceeded` + ); + } + + const newCount = this._participantCount.increment(); + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + year: this.year, + order: this.order, + status: this.status.toString(), + startDate: this.startDate, + endDate: this.endDate, + schedule: this.schedule, + scoringConfig: this.scoringConfig, + dropPolicy: this.dropPolicy, + stewardingConfig: this.stewardingConfig, + maxDrivers: this.maxDrivers, + participantCount: newCount.toNumber(), + }); + } + + /** + * Remove a participant from the season + */ + removeParticipant(): Season { + if (this._participantCount.isZero()) { + throw new RacingDomainInvariantError('Cannot remove participant: season has no participants'); + } + + const newCount = this._participantCount.decrement(); + + return Season.create({ + id: this.id, + leagueId: this.leagueId, + gameId: this.gameId, + name: this.name, + year: this.year, + order: this.order, + status: this.status.toString(), + startDate: this.startDate, + endDate: this.endDate, + schedule: this.schedule, + scoringConfig: this.scoringConfig, + dropPolicy: this.dropPolicy, + stewardingConfig: this.stewardingConfig, + maxDrivers: this.maxDrivers, + participantCount: newCount.toNumber(), }); } } \ No newline at end of file diff --git a/core/racing/domain/value-objects/LeagueVisibility.ts b/core/racing/domain/value-objects/LeagueVisibility.ts index 812603771..41c40549f 100644 --- a/core/racing/domain/value-objects/LeagueVisibility.ts +++ b/core/racing/domain/value-objects/LeagueVisibility.ts @@ -1,14 +1,10 @@ /** * 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. + * This is a hardened version that enforces strict business rules. */ - + import type { IValueObject } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; @@ -16,6 +12,7 @@ export type LeagueVisibilityType = 'ranked' | 'unranked'; export interface LeagueVisibilityConstraints { readonly minDrivers: number; + readonly maxDrivers: number; readonly isPubliclyVisible: boolean; readonly affectsRatings: boolean; readonly requiresApproval: boolean; @@ -24,22 +21,24 @@ export interface LeagueVisibilityConstraints { const VISIBILITY_CONSTRAINTS: Record = { ranked: { minDrivers: 10, + maxDrivers: 100, isPubliclyVisible: true, affectsRatings: true, - requiresApproval: false, // Anyone can join public leagues + requiresApproval: false, }, unranked: { minDrivers: 2, + maxDrivers: 50, isPubliclyVisible: false, affectsRatings: false, - requiresApproval: true, // Private leagues require invite/approval + requiresApproval: true, }, }; export interface LeagueVisibilityProps { type: LeagueVisibilityType; } - + export class LeagueVisibility implements IValueObject { readonly type: LeagueVisibilityType; readonly constraints: LeagueVisibilityConstraints; @@ -58,7 +57,6 @@ export class LeagueVisibility implements IValueObject { } static fromString(value: string): LeagueVisibility { - // Support both old ('public'/'private') and new ('ranked'/'unranked') terminology if (value === 'ranked' || value === 'public') { return LeagueVisibility.ranked(); } @@ -70,32 +68,76 @@ export class LeagueVisibility implements IValueObject { /** * 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`, + error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers` + }; + } + if (driverCount > this.constraints.maxDrivers) { + return { + valid: false, + error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues cannot exceed ${this.constraints.maxDrivers} drivers` }; } return { valid: true }; } /** - * Returns true if this is a ranked/public league + * Validates that the given max participants is appropriate for this visibility + */ + validateMaxParticipants(maxParticipants: number): { valid: boolean; error?: string } { + if (maxParticipants < this.constraints.minDrivers) { + return { + valid: false, + error: `Max participants must be at least ${this.constraints.minDrivers} for ${this.type} leagues` + }; + } + if (maxParticipants > this.constraints.maxDrivers) { + return { + valid: false, + error: `Max participants cannot exceed ${this.constraints.maxDrivers} for ${this.type} leagues` + }; + } + return { valid: true }; + } + + /** + * Check if this is a ranked/public league */ isRanked(): boolean { return this.type === 'ranked'; } /** - * Returns true if this is an unranked/private league + * Check if this is an unranked/private league */ isUnranked(): boolean { return this.type === 'unranked'; } + /** + * Get minimum required drivers + */ + getMinDrivers(): number { + return this.constraints.minDrivers; + } + + /** + * Get maximum allowed drivers + */ + getMaxDrivers(): number { + return this.constraints.maxDrivers; + } + + /** + * Check if the given driver count meets minimum requirements + */ + meetsMinimumForVisibility(driverCount: number): boolean { + return driverCount >= this.constraints.minDrivers; + } /** * Convert to string for serialization @@ -104,10 +146,6 @@ export class LeagueVisibility implements IValueObject { return this.type; } - get props(): LeagueVisibilityProps { - return { type: this.type }; - } - /** * For backward compatibility with existing 'public'/'private' terminology */ @@ -115,6 +153,10 @@ export class LeagueVisibility implements IValueObject { return this.type === 'ranked' ? 'public' : 'private'; } + get props(): LeagueVisibilityProps { + return { type: this.type }; + } + equals(other: IValueObject): boolean { return this.props.type === other.props.type; } @@ -122,4 +164,6 @@ export class LeagueVisibility implements IValueObject { // Export constants for validation export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers; -export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers; \ No newline at end of file +export const MAX_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.maxDrivers; +export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers; +export const MAX_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.maxDrivers; \ No newline at end of file diff --git a/core/racing/domain/value-objects/MaxParticipants.ts b/core/racing/domain/value-objects/MaxParticipants.ts new file mode 100644 index 000000000..4631ec274 --- /dev/null +++ b/core/racing/domain/value-objects/MaxParticipants.ts @@ -0,0 +1,97 @@ +/** + * Domain Value Object: MaxParticipants + * + * Represents the maximum number of participants allowed in a league or race. + * Enforces reasonable limits and constraints. + */ + +import type { IValueObject } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export interface MaxParticipantsProps { + value: number; +} + +export class MaxParticipants implements IValueObject { + readonly value: number; + + private constructor(value: number) { + this.value = value; + } + + static create(value: number): MaxParticipants { + if (!Number.isInteger(value) || value <= 0) { + throw new RacingDomainValidationError('Max participants must be a positive integer'); + } + + // Enforce reasonable upper limit to prevent system abuse + if (value > 100) { + throw new RacingDomainValidationError('Max participants cannot exceed 100'); + } + + return new MaxParticipants(value); + } + + /** + * Validate that max participants meets minimum requirements for ranked leagues + */ + validateForRankedLeague(): { valid: boolean; error?: string } { + if (this.value < 10) { + return { + valid: false, + error: 'Ranked leagues must allow at least 10 participants' + }; + } + return { valid: true }; + } + + /** + * Validate that max participants meets minimum requirements for unranked leagues + */ + validateForUnrankedLeague(): { valid: boolean; error?: string } { + if (this.value < 2) { + return { + valid: false, + error: 'Unranked leagues must allow at least 2 participants' + }; + } + return { valid: true }; + } + + /** + * Check if max participants is sufficient for the given participant count + */ + canAccommodate(participantCount: number): boolean { + return participantCount <= this.value; + } + + /** + * Check if max participants is at least the given minimum + */ + isAtLeast(min: number): boolean { + return this.value >= min; + } + + /** + * Check if max participants is exactly the given value + */ + isExactly(value: number): boolean { + return this.value === value; + } + + get props(): MaxParticipantsProps { + return { value: this.value }; + } + + equals(other: IValueObject): boolean { + return this.value === other.props.value; + } + + toString(): string { + return this.value.toString(); + } + + toNumber(): number { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/ParticipantCount.ts b/core/racing/domain/value-objects/ParticipantCount.ts new file mode 100644 index 000000000..d56ca1cd2 --- /dev/null +++ b/core/racing/domain/value-objects/ParticipantCount.ts @@ -0,0 +1,121 @@ +/** + * Domain Value Object: ParticipantCount + * + * Represents the number of participants in a league or race. + * Enforces constraints based on league visibility and other business rules. + */ + +import type { IValueObject } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export interface ParticipantCountProps { + value: number; +} + +export class ParticipantCount implements IValueObject { + readonly value: number; + + private constructor(value: number) { + this.value = value; + } + + static create(value: number): ParticipantCount { + if (!Number.isInteger(value) || value < 0) { + throw new RacingDomainValidationError('Participant count must be a non-negative integer'); + } + return new ParticipantCount(value); + } + + /** + * Validate against minimum requirements for ranked leagues + */ + validateForRankedLeague(): { valid: boolean; error?: string } { + if (this.value < 10) { + return { + valid: false, + error: 'Ranked leagues require at least 10 participants' + }; + } + return { valid: true }; + } + + /** + * Validate against minimum requirements for unranked leagues + */ + validateForUnrankedLeague(): { valid: boolean; error?: string } { + if (this.value < 2) { + return { + valid: false, + error: 'Unranked leagues require at least 2 participants' + }; + } + return { valid: true }; + } + + /** + * Validate against maximum capacity + */ + validateAgainstMax(maxParticipants: number): { valid: boolean; error?: string } { + if (this.value > maxParticipants) { + return { + valid: false, + error: `Participant count (${this.value}) exceeds maximum capacity (${maxParticipants})` + }; + } + return { valid: true }; + } + + /** + * Check if count meets minimum for given visibility type + */ + meetsMinimumForVisibility(isRanked: boolean): boolean { + return isRanked ? this.value >= 10 : this.value >= 2; + } + + /** + * Increment count by 1 + */ + increment(): ParticipantCount { + return new ParticipantCount(this.value + 1); + } + + /** + * Decrement count by 1 (if > 0) + */ + decrement(): ParticipantCount { + if (this.value === 0) { + throw new RacingDomainValidationError('Cannot decrement below zero'); + } + return new ParticipantCount(this.value - 1); + } + + /** + * Check if count is zero + */ + isZero(): boolean { + return this.value === 0; + } + + /** + * Check if count is at least the given minimum + */ + isAtLeast(min: number): boolean { + return this.value >= min; + } + + get props(): ParticipantCountProps { + return { value: this.value }; + } + + equals(other: IValueObject): boolean { + return this.value === other.props.value; + } + + toString(): string { + return this.value.toString(); + } + + toNumber(): number { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/RaceStatus.ts b/core/racing/domain/value-objects/RaceStatus.ts new file mode 100644 index 000000000..262dde1f4 --- /dev/null +++ b/core/racing/domain/value-objects/RaceStatus.ts @@ -0,0 +1,128 @@ +/** + * Domain Value Object: RaceStatus + * + * Represents the status of a race with strict lifecycle rules. + */ + +import type { IValueObject } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export type RaceStatusValue = 'scheduled' | 'running' | 'completed' | 'cancelled'; + +export interface RaceStatusProps { + value: RaceStatusValue; +} + +export class RaceStatus implements IValueObject { + readonly value: RaceStatusValue; + + private constructor(value: RaceStatusValue) { + this.value = value; + } + + static create(value: RaceStatusValue): RaceStatus { + if (!value) { + throw new RacingDomainValidationError('Race status is required'); + } + return new RaceStatus(value); + } + + /** + * Check if race can be started + */ + canStart(): boolean { + return this.value === 'scheduled'; + } + + /** + * Check if race can be completed + */ + canComplete(): boolean { + return this.value === 'running'; + } + + /** + * Check if race can be cancelled + */ + canCancel(): boolean { + return this.value === 'scheduled' || this.value === 'running'; + } + + /** + * Check if race can be reopened + */ + canReopen(): boolean { + return this.value === 'completed' || this.value === 'cancelled'; + } + + /** + * Check if race is in a terminal state + */ + isTerminal(): boolean { + return this.value === 'completed' || this.value === 'cancelled'; + } + + /** + * Check if race is running + */ + isRunning(): boolean { + return this.value === 'running'; + } + + /** + * Check if race is completed + */ + isCompleted(): boolean { + return this.value === 'completed'; + } + + /** + * Check if race is scheduled + */ + isScheduled(): boolean { + return this.value === 'scheduled'; + } + + /** + * Check if race is cancelled + */ + isCancelled(): boolean { + return this.value === 'cancelled'; + } + + /** + * Validate transition from current status to target status + */ + canTransitionTo(target: RaceStatusValue): { valid: boolean; error?: string } { + const current = this.value; + + // Define allowed transitions + const allowedTransitions: Record = { + scheduled: ['running', 'cancelled'], + running: ['completed', 'cancelled'], + completed: [], + cancelled: [], + }; + + if (!allowedTransitions[current].includes(target)) { + return { + valid: false, + error: `Cannot transition from ${current} to ${target}` + }; + } + + return { valid: true }; + } + + get props(): RaceStatusProps { + return { value: this.value }; + } + + equals(other: IValueObject): boolean { + return this.value === other.props.value; + } + + toString(): RaceStatusValue { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/SeasonStatus.ts b/core/racing/domain/value-objects/SeasonStatus.ts new file mode 100644 index 000000000..c430b3b2f --- /dev/null +++ b/core/racing/domain/value-objects/SeasonStatus.ts @@ -0,0 +1,136 @@ +/** + * Domain Value Object: SeasonStatus + * + * Represents the status of a season with strict lifecycle rules. + */ + +import type { IValueObject } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export type SeasonStatusValue = 'planned' | 'active' | 'completed' | 'archived' | 'cancelled'; + +export interface SeasonStatusProps { + value: SeasonStatusValue; +} + +export class SeasonStatus implements IValueObject { + readonly value: SeasonStatusValue; + + private constructor(value: SeasonStatusValue) { + this.value = value; + } + + static create(value: SeasonStatusValue): SeasonStatus { + if (!value) { + throw new RacingDomainValidationError('Season status is required'); + } + return new SeasonStatus(value); + } + + /** + * Check if season can be activated + */ + canActivate(): boolean { + return this.value === 'planned'; + } + + /** + * Check if season can be completed + */ + canComplete(): boolean { + return this.value === 'active'; + } + + /** + * Check if season can be archived + */ + canArchive(): boolean { + return this.value === 'completed'; + } + + /** + * Check if season can be cancelled + */ + canCancel(): boolean { + return this.value === 'planned' || this.value === 'active'; + } + + /** + * Check if season is in a terminal state + */ + isTerminal(): boolean { + return this.value === 'completed' || this.value === 'archived' || this.value === 'cancelled'; + } + + /** + * Check if season is active + */ + isActive(): boolean { + return this.value === 'active'; + } + + /** + * Check if season is completed + */ + isCompleted(): boolean { + return this.value === 'completed'; + } + + /** + * Check if season is planned + */ + isPlanned(): boolean { + return this.value === 'planned'; + } + + /** + * Check if season is archived + */ + isArchived(): boolean { + return this.value === 'archived'; + } + + /** + * Check if season is cancelled + */ + isCancelled(): boolean { + return this.value === 'cancelled'; + } + + /** + * Validate transition from current status to target status + */ + canTransitionTo(target: SeasonStatusValue): { valid: boolean; error?: string } { + const current = this.value; + + // Define allowed transitions + const allowedTransitions: Record = { + planned: ['active', 'cancelled'], + active: ['completed', 'cancelled'], + completed: ['archived'], + archived: [], + cancelled: [], + }; + + if (!allowedTransitions[current].includes(target)) { + return { + valid: false, + error: `Cannot transition from ${current} to ${target}` + }; + } + + return { valid: true }; + } + + get props(): SeasonStatusProps { + return { value: this.value }; + } + + equals(other: IValueObject): boolean { + return this.value === other.props.value; + } + + toString(): SeasonStatusValue { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/SessionDuration.ts b/core/racing/domain/value-objects/SessionDuration.ts new file mode 100644 index 000000000..bf0681033 --- /dev/null +++ b/core/racing/domain/value-objects/SessionDuration.ts @@ -0,0 +1,98 @@ +/** + * Domain Value Object: SessionDuration + * + * Represents the duration of a racing session in minutes. + * Enforces reasonable limits for different session types. + */ + +import type { IValueObject } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export interface SessionDurationProps { + value: number; +} + +export class SessionDuration implements IValueObject { + readonly value: number; + + private constructor(value: number) { + this.value = value; + } + + static create(value: number): SessionDuration { + if (!Number.isInteger(value) || value <= 0) { + throw new RacingDomainValidationError('Session duration must be a positive integer'); + } + + // Enforce reasonable limits + if (value < 15) { + throw new RacingDomainValidationError('Session duration must be at least 15 minutes'); + } + + if (value > 240) { + throw new RacingDomainValidationError('Session duration cannot exceed 240 minutes (4 hours)'); + } + + return new SessionDuration(value); + } + + /** + * Check if duration is suitable for sprint racing + */ + isSprint(): boolean { + return this.value >= 15 && this.value <= 45; + } + + /** + * Check if duration is suitable for standard racing + */ + isStandard(): boolean { + return this.value > 45 && this.value <= 90; + } + + /** + * Check if duration is suitable for endurance racing + */ + isEndurance(): boolean { + return this.value > 90; + } + + /** + * Get duration classification + */ + getClassification(): 'sprint' | 'standard' | 'endurance' { + if (this.isSprint()) return 'sprint'; + if (this.isStandard()) return 'standard'; + return 'endurance'; + } + + /** + * Check if duration is within specified range + */ + isWithinRange(min: number, max: number): boolean { + return this.value >= min && this.value <= max; + } + + /** + * Get duration in hours + */ + inHours(): number { + return this.value / 60; + } + + get props(): SessionDurationProps { + return { value: this.value }; + } + + equals(other: IValueObject): boolean { + return this.value === other.props.value; + } + + toString(): string { + return `${this.value} minutes`; + } + + toNumber(): number { + return this.value; + } +} \ No newline at end of file