harden business rules

This commit is contained in:
2025-12-27 17:53:01 +01:00
parent 3efa978ee0
commit 0e7a01d81c
9 changed files with 1486 additions and 365 deletions

View File

@@ -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<LeagueId> {
@@ -88,6 +98,10 @@ export class League implements IEntity<LeagueId> {
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<LeagueId> {
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<LeagueId> {
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<LeagueId> {
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<LeagueId> {
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<LeagueId> {
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<LeagueId> {
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();
}
}

View File

@@ -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<string> {
readonly id: string;
readonly leagueId: string;
@@ -21,8 +25,8 @@ export class Race implements IEntity<string> {
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<string> {
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<string> {
/**
* 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<string> {
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<string> {
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<string> {
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<string> {
* 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;
}
}

View File

@@ -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<string> {
readonly id: string;
@@ -31,6 +27,9 @@ export class Season implements IEntity<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
...(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<string> {
...(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<string> {
* 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<string> {
...(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<string> {
* 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<string> {
...(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<string> {
* 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<string> {
...(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<string> {
* 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<string> {
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(),
});
}
}