This commit is contained in:
2025-12-07 00:18:02 +01:00
parent 70d5f5689e
commit 5ca2454853
20 changed files with 4461 additions and 790 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
/**
* 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.
*/
export type LeagueVisibilityType = 'ranked' | 'unranked';
export interface LeagueVisibilityConstraints {
readonly minDrivers: number;
readonly isPubliclyVisible: boolean;
readonly affectsRatings: boolean;
readonly requiresApproval: boolean;
}
const VISIBILITY_CONSTRAINTS: Record<LeagueVisibilityType, LeagueVisibilityConstraints> = {
ranked: {
minDrivers: 10,
isPubliclyVisible: true,
affectsRatings: true,
requiresApproval: false, // Anyone can join public leagues
},
unranked: {
minDrivers: 2,
isPubliclyVisible: false,
affectsRatings: false,
requiresApproval: true, // Private leagues require invite/approval
},
};
export class LeagueVisibility {
readonly type: LeagueVisibilityType;
readonly constraints: LeagueVisibilityConstraints;
private constructor(type: LeagueVisibilityType) {
this.type = type;
this.constraints = VISIBILITY_CONSTRAINTS[type];
}
static ranked(): LeagueVisibility {
return new LeagueVisibility('ranked');
}
static unranked(): LeagueVisibility {
return new LeagueVisibility('unranked');
}
static fromString(value: string): LeagueVisibility {
// Support both old ('public'/'private') and new ('ranked'/'unranked') terminology
if (value === 'ranked' || value === 'public') {
return LeagueVisibility.ranked();
}
if (value === 'unranked' || value === 'private') {
return LeagueVisibility.unranked();
}
throw new Error(`Invalid league visibility: ${value}`);
}
/**
* Validates that the given driver count meets the minimum requirement
* for this visibility type.
*/
validateDriverCount(driverCount: number): { valid: boolean; error?: string } {
if (driverCount < this.constraints.minDrivers) {
return {
valid: false,
error: `${this.type === 'ranked' ? 'Ranked' : 'Unranked'} leagues require at least ${this.constraints.minDrivers} drivers`,
};
}
return { valid: true };
}
/**
* Returns true if this is a ranked/public league
*/
isRanked(): boolean {
return this.type === 'ranked';
}
/**
* Returns true if this is an unranked/private league
*/
isUnranked(): boolean {
return this.type === 'unranked';
}
/**
* Human-readable label for UI display
*/
getLabel(): string {
return this.type === 'ranked' ? 'Ranked (Public)' : 'Unranked (Friends)';
}
/**
* Short description for UI tooltips
*/
getDescription(): string {
return this.type === 'ranked'
? 'Competitive league visible to everyone. Results affect driver ratings.'
: 'Private league for friends. Results do not affect ratings.';
}
/**
* Convert to string for serialization
*/
toString(): LeagueVisibilityType {
return this.type;
}
/**
* For backward compatibility with existing 'public'/'private' terminology
*/
toLegacyString(): 'public' | 'private' {
return this.type === 'ranked' ? 'public' : 'private';
}
equals(other: LeagueVisibility): boolean {
return this.type === other.type;
}
}
// Export constants for validation
export const MIN_RANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.ranked.minDrivers;
export const MIN_UNRANKED_LEAGUE_DRIVERS = VISIBILITY_CONSTRAINTS.unranked.minDrivers;