Files
gridpilot.gg/core/racing/domain/entities/League.ts
2026-01-16 16:46:57 +01:00

546 lines
18 KiB
TypeScript

/**
* Domain Entity: League
*
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { MediaReference } from '@core/domain/media/MediaReference';
import { Entity } from '@core/shared/domain/Entity';
import { RacingDomainInvariantError, RacingDomainValidationError } from '../errors/RacingDomainError';
import { LeagueVisibility, LeagueVisibilityType } from '../value-objects/LeagueVisibility';
import { MaxParticipants } from '../value-objects/MaxParticipants';
import { ParticipantCount } from '../value-objects/ParticipantCount';
import { SessionDuration } from '../value-objects/SessionDuration';
import { LeagueCreatedAt } from './LeagueCreatedAt';
import { LeagueDescription } from './LeagueDescription';
import { LeagueId } from './LeagueId';
import { LeagueName } from './LeagueName';
import { LeagueOwnerId } from './LeagueOwnerId';
import { LeagueSocialLinks } from './LeagueSocialLinks';
/**
* Stewarding decision mode for protests
*/
export type StewardingDecisionMode =
| 'admin_only' // Only admins can decide
| 'steward_decides' // Single steward makes decision
| 'steward_vote' // X stewards must vote to uphold
| 'member_vote' // X members must vote to uphold
| 'steward_veto' // Upheld unless X stewards vote against
| 'member_veto'; // Upheld unless X members vote against
export interface StewardingSettings {
/**
* How protest decisions are made
*/
decisionMode: StewardingDecisionMode;
/**
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
requiredVotes?: number;
/**
* Whether to require a defense from the accused before deciding
*/
requireDefense?: boolean;
/**
* Time limit (hours) for accused to submit defense
*/
defenseTimeLimit?: number;
/**
* Time limit (hours) for voting to complete
*/
voteTimeLimit?: number;
/**
* Time limit (hours) after race ends when protests can be filed
*/
protestDeadlineHours?: number;
/**
* Time limit (hours) after race ends when stewarding is closed (no more decisions)
*/
stewardingClosesHours?: number;
/**
* Whether to notify the accused when a protest is filed
*/
notifyAccusedOnProtest?: boolean;
/**
* Whether to notify eligible voters when a vote is required
*/
notifyOnVoteRequired?: boolean;
}
export interface LeagueSettings {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
/**
* Maximum number of drivers allowed in the league.
* Used for simple capacity display on the website.
*/
maxDrivers?: number;
/**
* Stewarding settings for protest handling
*/
stewarding?: StewardingSettings;
/**
* League visibility type (ranked/unranked)
* Determines participant requirements and rating impact
*/
visibility?: LeagueVisibilityType;
}
export class League extends Entity<LeagueId> {
readonly name: LeagueName;
readonly description: LeagueDescription;
readonly ownerId: LeagueOwnerId;
readonly settings: LeagueSettings;
readonly category?: string | undefined;
readonly createdAt: LeagueCreatedAt;
readonly socialLinks: LeagueSocialLinks | undefined;
readonly logoRef: MediaReference;
// Domain state for business rule enforcement
private readonly _participantCount: ParticipantCount;
private readonly _visibility: LeagueVisibility;
private constructor(props: {
id: LeagueId;
name: LeagueName;
description: LeagueDescription;
ownerId: LeagueOwnerId;
settings: LeagueSettings;
category?: string | undefined;
createdAt: LeagueCreatedAt;
socialLinks?: LeagueSocialLinks;
participantCount: ParticipantCount;
visibility: LeagueVisibility;
logoRef: MediaReference;
}) {
super(props.id);
this.name = props.name;
this.description = props.description;
this.ownerId = props.ownerId;
this.settings = props.settings;
this.category = props.category;
this.createdAt = props.createdAt;
this.socialLinks = props.socialLinks;
this._participantCount = props.participantCount;
this._visibility = props.visibility;
this.logoRef = props.logoRef;
}
/**
* Factory method to create a new League entity
* Enforces all business rules and invariants
*/
static create(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings?: Partial<LeagueSettings>;
category?: string | undefined;
createdAt?: Date;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
participantCount?: number;
logoRef?: MediaReference;
}): 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,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168, // 7 days
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
};
// Build final settings with validation
const defaultSettings: LeagueSettings = {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
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);
// Only validate minimum requirements if there are actual participants
// This allows leagues to be created empty and populated later
if (participantCount.toNumber() > 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({
id,
name,
description,
ownerId,
settings: finalSettings,
category: props.category,
createdAt,
...(socialLinks !== undefined ? { socialLinks } : {}),
participantCount,
visibility,
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
});
}
static rehydrate(props: {
id: string;
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
category?: string | undefined;
createdAt: Date;
participantCount: number;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
logoRef?: MediaReference;
}): League {
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);
const visibilityType = props.settings.visibility ?? 'ranked';
const visibility = LeagueVisibility.fromString(visibilityType);
const participantCount = ParticipantCount.create(props.participantCount);
const socialLinks = props.socialLinks
? LeagueSocialLinks.create(props.socialLinks)
: undefined;
return new League({
id,
name,
description,
ownerId,
settings: props.settings,
category: props.category,
createdAt,
...(socialLinks !== undefined ? { socialLinks } : {}),
participantCount,
visibility,
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
});
}
/**
* 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;
description: string;
ownerId: string;
settings: LeagueSettings;
category?: string | undefined;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
logoRef: MediaReference;
}>): League {
const name = props.name ? LeagueName.create(props.name) : this.name;
const description = props.description ? LeagueDescription.create(props.description) : this.description;
const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId;
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks;
const logoRef = 'logoRef' in props ? props.logoRef! : this.logoRef;
// 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: newSettings,
category: props.category ?? this.category,
createdAt: this.createdAt,
...(socialLinks !== undefined ? { socialLinks } : {}),
participantCount: this._participantCount,
visibility: this._visibility,
logoRef: logoRef,
});
}
/**
* 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,
category: this.category,
createdAt: this.createdAt,
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
participantCount: newCount,
visibility: this._visibility,
logoRef: this.logoRef,
});
}
/**
* 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,
category: this.category,
createdAt: this.createdAt,
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
participantCount: newCount,
visibility: this._visibility,
logoRef: this.logoRef,
});
}
/**
* 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();
}
equals(other: Entity<LeagueId>): boolean {
if (!(other instanceof League)) {
return false;
}
return this.id.equals(other.id);
}
}