Files
gridpilot.gg/core/racing/domain/entities/season/Season.ts
2025-12-29 00:24:56 +01:00

665 lines
25 KiB
TypeScript

import {
RacingDomainInvariantError,
RacingDomainValidationError,
} from '../../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
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;
readonly leagueId: string;
readonly gameId: string;
readonly name: string;
readonly year: number | undefined;
readonly order: number | undefined;
readonly status: SeasonStatus;
readonly startDate: Date | undefined;
readonly endDate: Date | undefined;
readonly schedule: SeasonSchedule | undefined;
readonly schedulePublished: boolean;
readonly scoringConfig: SeasonScoringConfig | undefined;
readonly dropPolicy: SeasonDropPolicy | undefined;
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;
gameId: string;
name: string;
year?: number;
order?: number;
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
schedulePublished: boolean;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
participantCount: ParticipantCount;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.gameId = props.gameId;
this.name = props.name;
this.year = props.year;
this.order = props.order;
this.status = props.status;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.schedule = props.schedule;
this.schedulePublished = props.schedulePublished;
this.scoringConfig = props.scoringConfig;
this.dropPolicy = props.dropPolicy;
this.stewardingConfig = props.stewardingConfig;
this.maxDrivers = props.maxDrivers;
this._participantCount = props.participantCount;
}
static create(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status?: SeasonStatusValue;
startDate?: Date;
endDate?: Date | undefined;
schedule?: SeasonSchedule;
schedulePublished?: boolean;
scoringConfig?: SeasonScoringConfig;
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');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('Season leagueId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new RacingDomainValidationError('Season gameId is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Season name is required');
}
// 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,
leagueId: props.leagueId,
gameId: props.gameId,
name: props.name,
...(props.year !== undefined ? { year: props.year } : {}),
...(props.order !== undefined ? { order: props.order } : {}),
status,
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
schedulePublished: props.schedulePublished ?? false,
...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}),
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
participantCount,
});
}
static rehydrate(props: {
id: string;
leagueId: string;
gameId: string;
name: string;
year?: number;
order?: number;
status: SeasonStatus;
startDate?: Date;
endDate?: Date;
schedule?: SeasonSchedule;
schedulePublished: boolean;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
participantCount: number;
}): Season {
const participantCount = ParticipantCount.create(props.participantCount);
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()})`,
);
}
}
return new Season({
id: props.id,
leagueId: props.leagueId,
gameId: props.gameId,
name: props.name,
...(props.year !== undefined ? { year: props.year } : {}),
...(props.order !== undefined ? { order: props.order } : {}),
status: props.status,
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
schedulePublished: props.schedulePublished,
...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
...(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.isCompleted();
}
/**
* Check if season is active
*/
isActive(): boolean {
return this.status.isActive();
}
/**
* Check if season is completed
*/
isCompleted(): boolean {
return this.status.isCompleted();
}
/**
* Check if season is planned (not yet active)
*/
isPlanned(): boolean {
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 {
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,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'active',
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 } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: this._participantCount.toNumber(),
});
}
/**
* Mark the season as completed.
*/
complete(): Season {
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,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'completed',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
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 } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: this._participantCount.toNumber(),
});
}
/**
* Archive a completed season.
*/
archive(): Season {
const transition = this.status.canTransitionTo('archived');
if (!transition.valid) {
throw new RacingDomainInvariantError(transition.error!);
}
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: 'archived',
...(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 } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: this._participantCount.toNumber(),
});
}
/**
* Cancel a planned or active season.
*/
cancel(): Season {
// If already cancelled, return this (idempotent).
if (this.status.isCancelled()) {
return this;
}
const transition = this.status.canTransitionTo('cancelled');
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,
gameId: this.gameId,
name: this.name,
...(this.year !== undefined ? { year: this.year } : {}),
...(this.order !== undefined ? { order: this.order } : {}),
status: 'cancelled',
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
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 } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: this._participantCount.toNumber(),
});
}
/**
* Update schedule while keeping other properties intact.
*/
withSchedule(schedule: SeasonSchedule): Season {
// 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({
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 } : {}),
schedule,
schedulePublished: this.schedulePublished,
...(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(),
});
}
withSchedulePublished(published: boolean): 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.toString(),
...(this.startDate !== undefined ? { startDate: this.startDate } : {}),
...(this.endDate !== undefined ? { endDate: this.endDate } : {}),
...(this.schedule !== undefined ? { schedule: this.schedule } : {}),
schedulePublished: published,
...(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 } : {}),
scoringConfig,
...(this.dropPolicy !== undefined ? { dropPolicy: this.dropPolicy } : {}),
...(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,
...(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 } : {}),
...(this.maxDrivers !== undefined ? { 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,
...(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 } : {}),
...(this.maxDrivers !== undefined ? { maxDrivers: this.maxDrivers } : {}),
participantCount: newCount.toNumber(),
});
}
}