This commit is contained in:
2025-12-12 14:23:40 +01:00
parent 6a88fe93ab
commit 2cd3bfbb47
58 changed files with 2866 additions and 260 deletions

View File

@@ -9,10 +9,20 @@ export interface MonthlyRecurrencePatternProps {
export class MonthlyRecurrencePattern implements IValueObject<MonthlyRecurrencePatternProps> {
readonly ordinal: 1 | 2 | 3 | 4;
readonly weekday: Weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday) {
this.ordinal = ordinal;
this.weekday = weekday;
constructor(ordinal: 1 | 2 | 3 | 4, weekday: Weekday);
constructor(props: MonthlyRecurrencePatternProps);
constructor(
ordinalOrProps: 1 | 2 | 3 | 4 | MonthlyRecurrencePatternProps,
weekday?: Weekday,
) {
if (typeof ordinalOrProps === 'object') {
this.ordinal = ordinalOrProps.ordinal;
this.weekday = ordinalOrProps.weekday;
} else {
this.ordinal = ordinalOrProps;
this.weekday = weekday as Weekday;
}
}
get props(): MonthlyRecurrencePatternProps {

View File

@@ -0,0 +1,59 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
export type SeasonDropStrategy = 'none' | 'bestNResults' | 'dropWorstN';
export interface SeasonDropPolicyProps {
strategy: SeasonDropStrategy;
/**
* Number of results to consider for strategies that require a count.
* - bestNResults: keep best N
* - dropWorstN: drop worst N
*/
n?: number;
}
export class SeasonDropPolicy implements IValueObject<SeasonDropPolicyProps> {
readonly strategy: SeasonDropStrategy;
readonly n?: number;
constructor(props: SeasonDropPolicyProps) {
if (!props.strategy) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.strategy is required',
);
}
if (props.strategy === 'bestNResults' || props.strategy === 'dropWorstN') {
if (props.n === undefined || !Number.isInteger(props.n) || props.n <= 0) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be a positive integer when using bestNResults or dropWorstN',
);
}
}
if (props.strategy === 'none' && props.n !== undefined) {
throw new RacingDomainValidationError(
'SeasonDropPolicy.n must be undefined when strategy is none',
);
}
this.strategy = props.strategy;
if (props.n !== undefined) {
this.n = props.n;
}
}
get props(): SeasonDropPolicyProps {
return {
strategy: this.strategy,
...(this.n !== undefined ? { n: this.n } : {}),
};
}
equals(other: IValueObject<SeasonDropPolicyProps>): boolean {
const a = this.props;
const b = other.props;
return a.strategy === b.strategy && a.n === b.n;
}
}

View File

@@ -0,0 +1,66 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: SeasonScoringConfig
*
* Represents the scoring configuration owned by a Season.
* It is intentionally lightweight and primarily captures which
* preset (or custom mode) is applied for this Season.
*
* Detailed championship scoring rules are still modeled via
* `LeagueScoringConfig` and related types.
*/
export interface SeasonScoringConfigProps {
/**
* Identifier of the scoring preset applied to this Season.
* Examples:
* - 'sprint-main-driver'
* - 'club-default'
* - 'endurance-main-double'
* - 'custom'
*/
scoringPresetId: string;
/**
* Whether the Season uses custom scoring rather than a pure preset.
* When true, `scoringPresetId` acts as a label rather than a strict preset key.
*/
customScoringEnabled?: boolean;
}
export class SeasonScoringConfig
implements IValueObject<SeasonScoringConfigProps>
{
readonly scoringPresetId: string;
readonly customScoringEnabled: boolean;
constructor(params: SeasonScoringConfigProps) {
if (!params.scoringPresetId || params.scoringPresetId.trim().length === 0) {
throw new RacingDomainValidationError(
'SeasonScoringConfig.scoringPresetId must be a non-empty string',
);
}
this.scoringPresetId = params.scoringPresetId.trim();
this.customScoringEnabled = Boolean(params.customScoringEnabled);
}
get props(): SeasonScoringConfigProps {
return {
scoringPresetId: this.scoringPresetId,
...(this.customScoringEnabled
? { customScoringEnabled: this.customScoringEnabled }
: {}),
};
}
equals(other: IValueObject<SeasonScoringConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.scoringPresetId === b.scoringPresetId &&
Boolean(a.customScoringEnabled) === Boolean(b.customScoringEnabled)
);
}
}

View File

@@ -0,0 +1,131 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IValueObject } from '@gridpilot/shared/domain';
import type { StewardingDecisionMode } from '../entities/League';
export interface SeasonStewardingConfigProps {
decisionMode: StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
}
/**
* Value Object: SeasonStewardingConfig
*
* Encapsulates stewarding configuration owned by a Season.
* Shape intentionally mirrors LeagueStewardingFormDTO used by the wizard.
*/
export class SeasonStewardingConfig
implements IValueObject<SeasonStewardingConfigProps>
{
readonly decisionMode: StewardingDecisionMode;
readonly requiredVotes?: number;
readonly requireDefense: boolean;
readonly defenseTimeLimit: number;
readonly voteTimeLimit: number;
readonly protestDeadlineHours: number;
readonly stewardingClosesHours: number;
readonly notifyAccusedOnProtest: boolean;
readonly notifyOnVoteRequired: boolean;
constructor(props: SeasonStewardingConfigProps) {
if (!props.decisionMode) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.decisionMode is required',
);
}
if (
(props.decisionMode === 'steward_vote' ||
props.decisionMode === 'member_vote' ||
props.decisionMode === 'steward_veto' ||
props.decisionMode === 'member_veto') &&
(props.requiredVotes === undefined ||
!Number.isInteger(props.requiredVotes) ||
props.requiredVotes <= 0)
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.requiredVotes must be a positive integer for voting/veto modes',
);
}
if (!Number.isInteger(props.defenseTimeLimit) || props.defenseTimeLimit < 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.defenseTimeLimit must be a non-negative integer (hours)',
);
}
if (!Number.isInteger(props.voteTimeLimit) || props.voteTimeLimit <= 0) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.voteTimeLimit must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.protestDeadlineHours) ||
props.protestDeadlineHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.protestDeadlineHours must be a positive integer (hours)',
);
}
if (
!Number.isInteger(props.stewardingClosesHours) ||
props.stewardingClosesHours <= 0
) {
throw new RacingDomainValidationError(
'SeasonStewardingConfig.stewardingClosesHours must be a positive integer (hours)',
);
}
this.decisionMode = props.decisionMode;
if (props.requiredVotes !== undefined) {
this.requiredVotes = props.requiredVotes;
}
this.requireDefense = props.requireDefense;
this.defenseTimeLimit = props.defenseTimeLimit;
this.voteTimeLimit = props.voteTimeLimit;
this.protestDeadlineHours = props.protestDeadlineHours;
this.stewardingClosesHours = props.stewardingClosesHours;
this.notifyAccusedOnProtest = props.notifyAccusedOnProtest;
this.notifyOnVoteRequired = props.notifyOnVoteRequired;
}
get props(): SeasonStewardingConfigProps {
return {
decisionMode: this.decisionMode,
...(this.requiredVotes !== undefined
? { requiredVotes: this.requiredVotes }
: {}),
requireDefense: this.requireDefense,
defenseTimeLimit: this.defenseTimeLimit,
voteTimeLimit: this.voteTimeLimit,
protestDeadlineHours: this.protestDeadlineHours,
stewardingClosesHours: this.stewardingClosesHours,
notifyAccusedOnProtest: this.notifyAccusedOnProtest,
notifyOnVoteRequired: this.notifyOnVoteRequired,
};
}
equals(other: IValueObject<SeasonStewardingConfigProps>): boolean {
const a = this.props;
const b = other.props;
return (
a.decisionMode === b.decisionMode &&
a.requiredVotes === b.requiredVotes &&
a.requireDefense === b.requireDefense &&
a.defenseTimeLimit === b.defenseTimeLimit &&
a.voteTimeLimit === b.voteTimeLimit &&
a.protestDeadlineHours === b.protestDeadlineHours &&
a.stewardingClosesHours === b.stewardingClosesHours &&
a.notifyAccusedOnProtest === b.notifyAccusedOnProtest &&
a.notifyOnVoteRequired === b.notifyOnVoteRequired
);
}
}

View File

@@ -10,6 +10,10 @@ export interface WeekdaySetProps {
export class WeekdaySet implements IValueObject<WeekdaySetProps> {
private readonly days: Weekday[];
static fromArray(days: Weekday[]): WeekdaySet {
return new WeekdaySet(days);
}
constructor(days: Weekday[]) {
if (!Array.isArray(days) || days.length === 0) {
throw new RacingDomainValidationError('WeekdaySet requires at least one weekday');