rating
This commit is contained in:
407
core/identity/domain/services/AdminTrustRatingCalculator.test.ts
Normal file
407
core/identity/domain/services/AdminTrustRatingCalculator.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { AdminTrustRatingCalculator, VoteOutcomeInput, SystemSignalInput } from './AdminTrustRatingCalculator';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
import { AdminVoteOutcome } from '../entities/AdminVoteSession';
|
||||
|
||||
describe('AdminTrustRatingCalculator', () => {
|
||||
describe('calculate', () => {
|
||||
it('should sum all event deltas', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(5),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: {
|
||||
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(-2),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'adminAction', id: 'action-456' },
|
||||
reason: {
|
||||
code: 'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = AdminTrustRatingCalculator.calculate(events);
|
||||
expect(result).toBe(3); // 5 + (-2)
|
||||
});
|
||||
|
||||
it('should handle empty events array', () => {
|
||||
const result = AdminTrustRatingCalculator.calculate([]);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply weight to deltas', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(10),
|
||||
weight: 2,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: {
|
||||
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = AdminTrustRatingCalculator.calculate(events);
|
||||
expect(result).toBe(20); // 10 * 2
|
||||
});
|
||||
|
||||
it('should handle mixed weighted and unweighted events', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: {
|
||||
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(3),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'adminAction', id: 'action-456' },
|
||||
reason: {
|
||||
code: 'ADMIN_ACTION_SLA_BONUS',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = AdminTrustRatingCalculator.calculate(events);
|
||||
expect(result).toBe(8); // (5 * 1) + 3
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromVote', () => {
|
||||
it('should calculate positive outcome with full participation', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 100,
|
||||
count: { positive: 10, negative: 0, total: 10 },
|
||||
eligibleVoterCount: 10,
|
||||
participationRate: 100,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 10,
|
||||
voteCount: 10,
|
||||
percentPositive: 100,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
expect(delta.value).toBe(20); // Full positive, full participation
|
||||
});
|
||||
|
||||
it('should calculate negative outcome with full participation', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 0,
|
||||
count: { positive: 0, negative: 10, total: 10 },
|
||||
eligibleVoterCount: 10,
|
||||
participationRate: 100,
|
||||
outcome: 'negative',
|
||||
},
|
||||
eligibleVoterCount: 10,
|
||||
voteCount: 10,
|
||||
percentPositive: 0,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
expect(delta.value).toBe(-20); // Full negative, full participation
|
||||
});
|
||||
|
||||
it('should calculate partial positive outcome', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 75,
|
||||
count: { positive: 3, negative: 1, total: 4 },
|
||||
eligibleVoterCount: 4,
|
||||
participationRate: 100,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 4,
|
||||
voteCount: 4,
|
||||
percentPositive: 75,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
expect(delta.value).toBe(15); // 75% of 20 = 15
|
||||
});
|
||||
|
||||
it('should reduce delta for low participation', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 100,
|
||||
count: { positive: 2, negative: 0, total: 2 },
|
||||
eligibleVoterCount: 10,
|
||||
participationRate: 20,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 10,
|
||||
voteCount: 2,
|
||||
percentPositive: 100,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
// 20 * 0.5 (minimum participation multiplier) = 10
|
||||
expect(delta.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should handle tie outcome', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 50,
|
||||
count: { positive: 5, negative: 5, total: 10 },
|
||||
eligibleVoterCount: 10,
|
||||
participationRate: 100,
|
||||
outcome: 'tie',
|
||||
},
|
||||
eligibleVoterCount: 10,
|
||||
voteCount: 10,
|
||||
percentPositive: 50,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
expect(delta.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should return zero for no votes', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 0,
|
||||
count: { positive: 0, negative: 0, total: 0 },
|
||||
eligibleVoterCount: 10,
|
||||
participationRate: 0,
|
||||
outcome: 'tie',
|
||||
},
|
||||
eligibleVoterCount: 10,
|
||||
voteCount: 0,
|
||||
percentPositive: 0,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
expect(delta.value).toBe(0);
|
||||
});
|
||||
|
||||
it('should round to 2 decimal places', () => {
|
||||
const input: VoteOutcomeInput = {
|
||||
outcome: {
|
||||
percentPositive: 66.67,
|
||||
count: { positive: 2, negative: 1, total: 3 },
|
||||
eligibleVoterCount: 4,
|
||||
participationRate: 75,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 4,
|
||||
voteCount: 3,
|
||||
percentPositive: 66.67,
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
|
||||
// 66.67% of 20 = 13.334, * 0.75 (participation) = 10.0005, rounded = 10.00
|
||||
expect(delta.value).toBe(10.00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromSystemSignal', () => {
|
||||
it('should calculate SLA response bonus', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'sla_response',
|
||||
details: {},
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(5);
|
||||
});
|
||||
|
||||
it('should calculate minor reversal penalty', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'reversal',
|
||||
details: {},
|
||||
severity: 'minor',
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(-10);
|
||||
});
|
||||
|
||||
it('should calculate major reversal penalty', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'reversal',
|
||||
details: {},
|
||||
severity: 'major',
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should calculate rule clarity bonus', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'rule_clarity',
|
||||
details: {},
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(3);
|
||||
});
|
||||
|
||||
it('should calculate minor abuse report penalty', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'abuse_report',
|
||||
details: {},
|
||||
severity: 'minor',
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(-15);
|
||||
});
|
||||
|
||||
it('should calculate major abuse report penalty', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'abuse_report',
|
||||
details: {},
|
||||
severity: 'major',
|
||||
};
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(-30);
|
||||
});
|
||||
|
||||
it('should default to zero for unknown action type', () => {
|
||||
const input: SystemSignalInput = {
|
||||
actionType: 'sla_response' as any,
|
||||
details: {},
|
||||
};
|
||||
|
||||
// Override for test
|
||||
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
|
||||
expect(delta.value).toBe(5); // Known type
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromMultipleVotes', () => {
|
||||
it('should sum multiple vote outcomes', () => {
|
||||
const inputs: VoteOutcomeInput[] = [
|
||||
{
|
||||
outcome: {
|
||||
percentPositive: 100,
|
||||
count: { positive: 5, negative: 0, total: 5 },
|
||||
eligibleVoterCount: 5,
|
||||
participationRate: 100,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 5,
|
||||
voteCount: 5,
|
||||
percentPositive: 100,
|
||||
},
|
||||
{
|
||||
outcome: {
|
||||
percentPositive: 0,
|
||||
count: { positive: 0, negative: 3, total: 3 },
|
||||
eligibleVoterCount: 3,
|
||||
participationRate: 100,
|
||||
outcome: 'negative',
|
||||
},
|
||||
eligibleVoterCount: 3,
|
||||
voteCount: 3,
|
||||
percentPositive: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromMultipleVotes(inputs);
|
||||
expect(delta.value).toBe(0); // +20 + (-20) = 0
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateFromMultipleSystemSignals', () => {
|
||||
it('should sum multiple system signals', () => {
|
||||
const inputs: SystemSignalInput[] = [
|
||||
{ actionType: 'sla_response', details: {} },
|
||||
{ actionType: 'reversal', details: {}, severity: 'minor' },
|
||||
{ actionType: 'rule_clarity', details: {} },
|
||||
];
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateFromMultipleSystemSignals(inputs);
|
||||
expect(delta.value).toBe(-2); // 5 + (-10) + 3 = -2
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTotalDelta', () => {
|
||||
it('should combine votes and system signals', () => {
|
||||
const voteInputs: VoteOutcomeInput[] = [
|
||||
{
|
||||
outcome: {
|
||||
percentPositive: 75,
|
||||
count: { positive: 3, negative: 1, total: 4 },
|
||||
eligibleVoterCount: 4,
|
||||
participationRate: 100,
|
||||
outcome: 'positive',
|
||||
},
|
||||
eligibleVoterCount: 4,
|
||||
voteCount: 4,
|
||||
percentPositive: 75,
|
||||
},
|
||||
];
|
||||
|
||||
const systemInputs: SystemSignalInput[] = [
|
||||
{ actionType: 'sla_response', details: {} },
|
||||
{ actionType: 'reversal', details: {}, severity: 'minor' },
|
||||
];
|
||||
|
||||
const delta = AdminTrustRatingCalculator.calculateTotalDelta(voteInputs, systemInputs);
|
||||
expect(delta.value).toBe(8); // 15 (vote) + 5 (SLA) + (-10) (reversal) = 10
|
||||
});
|
||||
|
||||
it('should handle empty inputs', () => {
|
||||
const delta = AdminTrustRatingCalculator.calculateTotalDelta([], []);
|
||||
expect(delta.value).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
164
core/identity/domain/services/AdminTrustRatingCalculator.ts
Normal file
164
core/identity/domain/services/AdminTrustRatingCalculator.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { AdminVoteOutcome } from '../entities/AdminVoteSession';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
/**
|
||||
* Input for vote outcome calculation
|
||||
*/
|
||||
export interface VoteOutcomeInput {
|
||||
outcome: AdminVoteOutcome;
|
||||
eligibleVoterCount: number;
|
||||
voteCount: number;
|
||||
percentPositive: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for system signal calculation
|
||||
*/
|
||||
export interface SystemSignalInput {
|
||||
actionType: 'sla_response' | 'reversal' | 'rule_clarity' | 'abuse_report';
|
||||
details: Record<string, unknown>;
|
||||
severity?: 'minor' | 'major';
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: AdminTrustRatingCalculator
|
||||
*
|
||||
* Pure, stateless calculator for admin trust rating.
|
||||
* Implements full logic per ratings-architecture-concept.md sections 5.2 and 7.1.1
|
||||
*/
|
||||
export class AdminTrustRatingCalculator {
|
||||
/**
|
||||
* Calculate admin trust rating delta from events
|
||||
*
|
||||
* Logic:
|
||||
* - Vote outcomes: weighted by participation and percentage
|
||||
* - System signals: fixed deltas based on action type
|
||||
* - All events are summed with their weights
|
||||
*/
|
||||
static calculate(events: RatingEvent[]): number {
|
||||
return events.reduce((sum, event) => {
|
||||
// Apply weight if present, otherwise use delta directly
|
||||
const weightedDelta = event.weight ? event.delta.value * event.weight : event.delta.value;
|
||||
return sum + weightedDelta;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delta from vote outcome
|
||||
*
|
||||
* Based on section 5.2.1:
|
||||
* - Votes produce events with reference to voteSessionId
|
||||
* - Delta is weighted by eligible voter count and participation
|
||||
* - Range: -20 to +20 based on percentage
|
||||
*
|
||||
* @param input - Vote outcome data
|
||||
* @returns Rating delta
|
||||
*/
|
||||
static calculateFromVote(input: VoteOutcomeInput): RatingDelta {
|
||||
const { outcome, eligibleVoterCount, voteCount, percentPositive } = input;
|
||||
|
||||
// If no votes, no change
|
||||
if (voteCount === 0) {
|
||||
return RatingDelta.create(0);
|
||||
}
|
||||
|
||||
// Calculate base delta from percentage
|
||||
// Positive outcome: +1 to +20
|
||||
// Negative outcome: -1 to -20
|
||||
// Tie: 0
|
||||
let baseDelta: number;
|
||||
|
||||
if (outcome.outcome === 'positive') {
|
||||
baseDelta = (percentPositive / 100) * 20; // 0 to +20
|
||||
} else if (outcome.outcome === 'negative') {
|
||||
baseDelta = -((100 - percentPositive) / 100) * 20; // -20 to 0
|
||||
} else {
|
||||
baseDelta = 0; // Tie
|
||||
}
|
||||
|
||||
// Weight by participation rate (higher participation = more trust in result)
|
||||
// Minimum 50% participation for full weight
|
||||
const participationRate = voteCount / eligibleVoterCount;
|
||||
const participationMultiplier = Math.max(0.5, Math.min(1, participationRate));
|
||||
|
||||
const weightedDelta = baseDelta * participationMultiplier;
|
||||
|
||||
// Round to 2 decimal places
|
||||
const roundedDelta = Math.round(weightedDelta * 100) / 100;
|
||||
|
||||
return RatingDelta.create(roundedDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delta from system signal
|
||||
*
|
||||
* Based on section 5.2.2:
|
||||
* - ADMIN_ACTION_SLA_BONUS: +5
|
||||
* - ADMIN_ACTION_REVERSAL_PENALTY: -10 (minor) or -20 (major)
|
||||
* - ADMIN_ACTION_RULE_CLARITY_BONUS: +3
|
||||
* - ADMIN_ACTION_ABUSE_REPORT_PENALTY: -15 (minor) or -30 (major)
|
||||
*
|
||||
* @param input - System signal data
|
||||
* @returns Rating delta
|
||||
*/
|
||||
static calculateFromSystemSignal(input: SystemSignalInput): RatingDelta {
|
||||
const { actionType, severity } = input;
|
||||
|
||||
switch (actionType) {
|
||||
case 'sla_response':
|
||||
return RatingDelta.create(5);
|
||||
|
||||
case 'reversal':
|
||||
return RatingDelta.create(severity === 'major' ? -20 : -10);
|
||||
|
||||
case 'rule_clarity':
|
||||
return RatingDelta.create(3);
|
||||
|
||||
case 'abuse_report':
|
||||
return RatingDelta.create(severity === 'major' ? -30 : -15);
|
||||
|
||||
default:
|
||||
return RatingDelta.create(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined delta from multiple vote outcomes
|
||||
* Useful for batch processing
|
||||
*/
|
||||
static calculateFromMultipleVotes(inputs: VoteOutcomeInput[]): RatingDelta {
|
||||
const totalDelta = inputs.reduce((sum, input) => {
|
||||
const delta = this.calculateFromVote(input);
|
||||
return sum + delta.value;
|
||||
}, 0);
|
||||
|
||||
return RatingDelta.create(totalDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate combined delta from multiple system signals
|
||||
*/
|
||||
static calculateFromMultipleSystemSignals(inputs: SystemSignalInput[]): RatingDelta {
|
||||
const totalDelta = inputs.reduce((sum, input) => {
|
||||
const delta = this.calculateFromSystemSignal(input);
|
||||
return sum + delta.value;
|
||||
}, 0);
|
||||
|
||||
return RatingDelta.create(totalDelta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total delta from mixed sources
|
||||
* Combines votes and system signals
|
||||
*/
|
||||
static calculateTotalDelta(
|
||||
voteInputs: VoteOutcomeInput[],
|
||||
systemInputs: SystemSignalInput[]
|
||||
): RatingDelta {
|
||||
const voteDelta = this.calculateFromMultipleVotes(voteInputs);
|
||||
const systemDelta = this.calculateFromMultipleSystemSignals(systemInputs);
|
||||
|
||||
return RatingDelta.create(voteDelta.value + systemDelta.value);
|
||||
}
|
||||
}
|
||||
457
core/identity/domain/services/DrivingRatingCalculator.test.ts
Normal file
457
core/identity/domain/services/DrivingRatingCalculator.test.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { DrivingRatingCalculator, DrivingRaceFactsDto } from './DrivingRatingCalculator';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
describe('DrivingRatingCalculator', () => {
|
||||
describe('calculateFromRaceFacts', () => {
|
||||
it('should calculate delta for finished race with good performance', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
expect(results.has('user-123')).toBe(true);
|
||||
|
||||
const result = results.get('user-123')!;
|
||||
expect(result.userId).toBe('user-123');
|
||||
expect(result.delta).toBeGreaterThan(0); // Positive for good performance
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have performance event
|
||||
const performanceEvent = result.events.find(e => e.reasonCode === 'DRIVING_FINISH_STRENGTH_GAIN');
|
||||
expect(performanceEvent).toBeDefined();
|
||||
expect(performanceEvent?.delta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should calculate delta for finished race with poor performance', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 2,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
expect(result.delta).toBeLessThan(0); // Negative for poor performance
|
||||
});
|
||||
|
||||
it('should add incident penalties', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 3,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const incidentEvent = result.events.find(e => e.reasonCode === 'DRIVING_INCIDENTS_PENALTY');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta).toBeLessThan(0);
|
||||
expect(result.delta).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply DNS penalty', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const dnsEvent = result.events.find(e => e.reasonCode === 'DRIVING_DNS_PENALTY');
|
||||
expect(dnsEvent).toBeDefined();
|
||||
expect(dnsEvent?.delta).toBe(-15);
|
||||
expect(result.delta).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should apply DNF penalty', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dnf',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const dnfEvent = result.events.find(e => e.reasonCode === 'DRIVING_DNF_PENALTY');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta).toBe(-10);
|
||||
});
|
||||
|
||||
it('should apply DSQ penalty', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const dsqEvent = result.events.find(e => e.reasonCode === 'DRIVING_DSQ_PENALTY');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta).toBe(-25);
|
||||
});
|
||||
|
||||
it('should apply AFK penalty', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const afkEvent = result.events.find(e => e.reasonCode === 'DRIVING_AFK_PENALTY');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta).toBe(-20);
|
||||
});
|
||||
|
||||
it('should calculate positions gained bonus', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 10,
|
||||
finishPos: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 5,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
const result = results.get('user-123')!;
|
||||
|
||||
const gainEvent = result.events.find(e => e.reasonCode === 'DRIVING_POSITIONS_GAINED_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle multiple drivers in race', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 2,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-789',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
|
||||
expect(results.has('user-123')).toBe(true);
|
||||
expect(results.has('user-456')).toBe(true);
|
||||
expect(results.has('user-789')).toBe(true);
|
||||
|
||||
// user-123 should have positive delta
|
||||
expect(results.get('user-123')!.delta).toBeGreaterThan(0);
|
||||
|
||||
// user-456 should have negative delta (poor position + incidents)
|
||||
expect(results.get('user-456')!.delta).toBeLessThan(0);
|
||||
|
||||
// user-789 should have negative delta (DNS)
|
||||
expect(results.get('user-789')!.delta).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should calculate SoF if not provided', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof provided
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof provided
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
|
||||
// Should still calculate without errors
|
||||
expect(results.size).toBe(2);
|
||||
expect(results.get('user-123')!.events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle empty results array', () => {
|
||||
const facts: DrivingRaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
|
||||
expect(results.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculate', () => {
|
||||
it('should sum events with component weights', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(10),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
summary: 'Good finish',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-5),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_INCIDENTS_PENALTY',
|
||||
summary: '1 incident',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = DrivingRatingCalculator.calculate(events);
|
||||
|
||||
// Should apply weights: 10 * 0.5 + (-5) * 0.3 = 5 - 1.5 = 3.5
|
||||
// Then normalized by total weight (1 + 1 = 2)
|
||||
expect(result).toBeGreaterThan(0);
|
||||
expect(result).toBeLessThan(5);
|
||||
});
|
||||
|
||||
it('should handle empty events array', () => {
|
||||
const result = DrivingRatingCalculator.calculate([]);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should apply reliability weight to penalty events', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-15),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_DNS_PENALTY',
|
||||
summary: 'Did not start',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = DrivingRatingCalculator.calculate(events);
|
||||
|
||||
// Should apply reliability weight (0.2)
|
||||
expect(result).toBe(-15 * 0.2);
|
||||
});
|
||||
|
||||
it('should normalize by total weight', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(20),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-10),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_DNS_PENALTY',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = DrivingRatingCalculator.calculate(events);
|
||||
|
||||
// 20 * 0.5 + (-10) * 0.2 = 10 - 2 = 8
|
||||
// Normalized by (1 + 1) = 2
|
||||
// Result = 8 / 2 = 4
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle events with custom weights', () => {
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-123',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(10),
|
||||
weight: 2,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: {
|
||||
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
summary: 'Test',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = DrivingRatingCalculator.calculate(events);
|
||||
|
||||
// Should consider event weight
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal file
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
/**
|
||||
* Input DTO for driving rating calculation from race facts
|
||||
*/
|
||||
export interface DrivingRaceFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number; // Optional: strength of field (platform ratings or external)
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual driver calculation result
|
||||
*/
|
||||
export interface DriverCalculationResult {
|
||||
userId: string;
|
||||
delta: number;
|
||||
events: Array<{
|
||||
reasonCode: string;
|
||||
delta: number;
|
||||
weight: number;
|
||||
summary: string;
|
||||
details: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: DrivingRatingCalculator
|
||||
*
|
||||
* Pure, stateless calculator for driving rating.
|
||||
* Implements full logic per ratings-architecture-concept.md section 5.1.
|
||||
*
|
||||
* Key principles:
|
||||
* - Performance: position vs field strength
|
||||
* - Clean driving: incident penalties
|
||||
* - Reliability: DNS/DNF/DSQ/AFK penalties
|
||||
* - Weighted by event recency and confidence
|
||||
*/
|
||||
export class DrivingRatingCalculator {
|
||||
// Weights for different components (sum to 1.0)
|
||||
private static readonly PERFORMANCE_WEIGHT = 0.5;
|
||||
private static readonly CLEAN_DRIVING_WEIGHT = 0.3;
|
||||
private static readonly RELIABILITY_WEIGHT = 0.2;
|
||||
|
||||
// Penalty values for reliability issues
|
||||
private static readonly DNS_PENALTY = -15;
|
||||
private static readonly DNF_PENALTY = -10;
|
||||
private static readonly DSQ_PENALTY = -25;
|
||||
private static readonly AFK_PENALTY = -20;
|
||||
|
||||
// Incident penalty per incident
|
||||
private static readonly INCIDENT_PENALTY = -5;
|
||||
private static readonly MAJOR_INCIDENT_PENALTY = -15;
|
||||
|
||||
/**
|
||||
* Calculate driving rating deltas from race facts
|
||||
* Returns per-driver results with detailed event breakdown
|
||||
*/
|
||||
static calculateFromRaceFacts(facts: DrivingRaceFactsDto): Map<string, DriverCalculationResult> {
|
||||
const results = new Map<string, DriverCalculationResult>();
|
||||
|
||||
// Calculate field strength if not provided
|
||||
const fieldStrength = facts.results.length > 0
|
||||
? (facts.results
|
||||
.filter(r => r.status === 'finished')
|
||||
.reduce((sum, r) => sum + (r.sof || this.estimateDriverRating(r.userId)), 0) /
|
||||
Math.max(1, facts.results.filter(r => r.status === 'finished').length))
|
||||
: 0;
|
||||
|
||||
for (const result of facts.results) {
|
||||
const calculation = this.calculateDriverResult(result, fieldStrength, facts.results.length);
|
||||
results.set(result.userId, calculation);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate delta from existing rating events (for snapshot recomputation)
|
||||
* This is the "pure" calculation that sums weighted deltas
|
||||
*/
|
||||
static calculate(events: RatingEvent[]): number {
|
||||
if (events.length === 0) return 0;
|
||||
|
||||
// Group events by type and apply weights
|
||||
let totalDelta = 0;
|
||||
let performanceWeight = 0;
|
||||
let cleanDrivingWeight = 0;
|
||||
let reliabilityWeight = 0;
|
||||
|
||||
for (const event of events) {
|
||||
const reasonCode = event.reason.code;
|
||||
const delta = event.delta.value;
|
||||
const weight = event.weight || 1;
|
||||
|
||||
let componentWeight = 1;
|
||||
|
||||
if (this.isPerformanceEvent(reasonCode)) {
|
||||
componentWeight = this.PERFORMANCE_WEIGHT;
|
||||
performanceWeight += weight;
|
||||
} else if (this.isCleanDrivingEvent(reasonCode)) {
|
||||
componentWeight = this.CLEAN_DRIVING_WEIGHT;
|
||||
cleanDrivingWeight += weight;
|
||||
} else if (this.isReliabilityEvent(reasonCode)) {
|
||||
componentWeight = this.RELIABILITY_WEIGHT;
|
||||
reliabilityWeight += weight;
|
||||
}
|
||||
|
||||
// Apply component weight and event weight
|
||||
totalDelta += delta * componentWeight * weight;
|
||||
}
|
||||
|
||||
// Normalize by total weight to prevent inflation
|
||||
const totalWeight = performanceWeight + cleanDrivingWeight + reliabilityWeight;
|
||||
if (totalWeight > 0) {
|
||||
totalDelta = totalDelta / totalWeight;
|
||||
}
|
||||
|
||||
return Math.round(totalDelta * 100) / 100; // Round to 2 decimal places
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate result for a single driver
|
||||
*/
|
||||
private static calculateDriverResult(
|
||||
result: {
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number;
|
||||
},
|
||||
fieldStrength: number,
|
||||
totalDrivers: number
|
||||
): DriverCalculationResult {
|
||||
const events: Array<{
|
||||
reasonCode: string;
|
||||
delta: number;
|
||||
weight: number;
|
||||
summary: string;
|
||||
details: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
let totalDelta = 0;
|
||||
|
||||
// 1. Performance calculation (only for finished races)
|
||||
if (result.status === 'finished') {
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
result.startPos,
|
||||
result.finishPos,
|
||||
fieldStrength,
|
||||
totalDrivers
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push({
|
||||
reasonCode: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
delta: performanceDelta,
|
||||
weight: 1,
|
||||
summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${totalDrivers}`,
|
||||
details: {
|
||||
startPos: result.startPos,
|
||||
finishPos: result.finishPos,
|
||||
positionsGained: result.startPos - result.finishPos,
|
||||
fieldStrength: fieldStrength,
|
||||
totalDrivers: totalDrivers,
|
||||
},
|
||||
});
|
||||
totalDelta += performanceDelta * this.PERFORMANCE_WEIGHT;
|
||||
|
||||
// Positions gained bonus
|
||||
const positionsGained = result.startPos - result.finishPos;
|
||||
if (positionsGained > 0) {
|
||||
const gainBonus = Math.min(positionsGained * 2, 10);
|
||||
events.push({
|
||||
reasonCode: 'DRIVING_POSITIONS_GAINED_BONUS',
|
||||
delta: gainBonus,
|
||||
weight: 0.5,
|
||||
summary: `Gained ${positionsGained} positions`,
|
||||
details: { positionsGained },
|
||||
});
|
||||
totalDelta += gainBonus * this.PERFORMANCE_WEIGHT * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean driving calculation
|
||||
if (result.incidents > 0) {
|
||||
const incidentPenalty = Math.min(result.incidents * this.INCIDENT_PENALTY, -30);
|
||||
events.push({
|
||||
reasonCode: 'DRIVING_INCIDENTS_PENALTY',
|
||||
delta: incidentPenalty,
|
||||
weight: 1,
|
||||
summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
|
||||
details: { incidents: result.incidents },
|
||||
});
|
||||
totalDelta += incidentPenalty * this.CLEAN_DRIVING_WEIGHT;
|
||||
}
|
||||
|
||||
// 3. Reliability calculation
|
||||
if (result.status !== 'finished') {
|
||||
let reliabilityDelta = 0;
|
||||
let reasonCode = '';
|
||||
|
||||
switch (result.status) {
|
||||
case 'dns':
|
||||
reliabilityDelta = this.DNS_PENALTY;
|
||||
reasonCode = 'DRIVING_DNS_PENALTY';
|
||||
break;
|
||||
case 'dnf':
|
||||
reliabilityDelta = this.DNF_PENALTY;
|
||||
reasonCode = 'DRIVING_DNF_PENALTY';
|
||||
break;
|
||||
case 'dsq':
|
||||
reliabilityDelta = this.DSQ_PENALTY;
|
||||
reasonCode = 'DRIVING_DSQ_PENALTY';
|
||||
break;
|
||||
case 'afk':
|
||||
reliabilityDelta = this.AFK_PENALTY;
|
||||
reasonCode = 'DRIVING_AFK_PENALTY';
|
||||
break;
|
||||
}
|
||||
|
||||
events.push({
|
||||
reasonCode,
|
||||
delta: reliabilityDelta,
|
||||
weight: 1,
|
||||
summary: this.getStatusSummary(result.status),
|
||||
details: { status: result.status },
|
||||
});
|
||||
totalDelta += reliabilityDelta * this.RELIABILITY_WEIGHT;
|
||||
}
|
||||
|
||||
// Normalize total delta by component weights
|
||||
const componentsUsed = [
|
||||
result.status === 'finished' ? 1 : 0,
|
||||
result.incidents > 0 ? 1 : 0,
|
||||
result.status !== 'finished' ? 1 : 0,
|
||||
].reduce((sum, val) => sum + val, 0);
|
||||
|
||||
if (componentsUsed > 0) {
|
||||
// The totalDelta is already weighted, but we need to normalize
|
||||
// to ensure the final result is within reasonable bounds
|
||||
const maxPossible = 50; // Max positive
|
||||
const minPossible = -50; // Max negative
|
||||
totalDelta = Math.max(minPossible, Math.min(maxPossible, totalDelta));
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.userId,
|
||||
delta: Math.round(totalDelta * 100) / 100,
|
||||
events,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance delta based on position vs field strength
|
||||
*/
|
||||
private static calculatePerformanceDelta(
|
||||
startPos: number,
|
||||
finishPos: number,
|
||||
fieldStrength: number,
|
||||
totalDrivers: number
|
||||
): number {
|
||||
// Base performance score from position (reverse percentile)
|
||||
// Higher position score = better performance
|
||||
const positionScore = ((totalDrivers - finishPos + 1) / totalDrivers) * 100;
|
||||
|
||||
// Expected score (50th percentile baseline)
|
||||
const expectedScore = 50;
|
||||
|
||||
// Field strength multiplier (higher = harder competition, bigger rewards)
|
||||
// Normalize to 0.8-2.0 range
|
||||
const fieldMultiplier = 0.8 + Math.min(fieldStrength / 2000, 1.2);
|
||||
|
||||
// Performance delta: how much better/worse than expected
|
||||
let delta = (positionScore - expectedScore) * fieldMultiplier;
|
||||
|
||||
// Bonus for positions gained/lost
|
||||
const positionsGained = startPos - finishPos;
|
||||
delta += positionsGained * 2;
|
||||
|
||||
// Clamp to reasonable range
|
||||
return Math.max(-30, Math.min(30, delta));
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate driver rating for SoF calculation
|
||||
* This is a placeholder - in real implementation, would query user rating snapshot
|
||||
*/
|
||||
private static estimateDriverRating(userId: string): number {
|
||||
// Default rating for new drivers
|
||||
return 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ordinal suffix for position
|
||||
*/
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable summary for status
|
||||
*/
|
||||
private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string {
|
||||
switch (status) {
|
||||
case 'finished': return 'Race completed';
|
||||
case 'dnf': return 'Did not finish';
|
||||
case 'dns': return 'Did not start';
|
||||
case 'dsq': return 'Disqualified';
|
||||
case 'afk': return 'AFK / Not responsive';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reason code is performance-related
|
||||
*/
|
||||
private static isPerformanceEvent(reasonCode: string): boolean {
|
||||
return reasonCode === 'DRIVING_FINISH_STRENGTH_GAIN' ||
|
||||
reasonCode === 'DRIVING_POSITIONS_GAINED_BONUS' ||
|
||||
reasonCode === 'DRIVING_PACE_RELATIVE_GAIN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reason code is clean driving-related
|
||||
*/
|
||||
private static isCleanDrivingEvent(reasonCode: string): boolean {
|
||||
return reasonCode === 'DRIVING_INCIDENTS_PENALTY' ||
|
||||
reasonCode === 'DRIVING_MAJOR_CONTACT_PENALTY' ||
|
||||
reasonCode === 'DRIVING_PENALTY_INVOLVEMENT_PENALTY';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reason code is reliability-related
|
||||
*/
|
||||
private static isReliabilityEvent(reasonCode: string): boolean {
|
||||
return reasonCode === 'DRIVING_DNS_PENALTY' ||
|
||||
reasonCode === 'DRIVING_DNF_PENALTY' ||
|
||||
reasonCode === 'DRIVING_DSQ_PENALTY' ||
|
||||
reasonCode === 'DRIVING_AFK_PENALTY' ||
|
||||
reasonCode === 'DRIVING_SEASON_ATTENDANCE_BONUS';
|
||||
}
|
||||
}
|
||||
320
core/identity/domain/services/EligibilityEvaluator.test.ts
Normal file
320
core/identity/domain/services/EligibilityEvaluator.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Tests for EligibilityEvaluator
|
||||
*/
|
||||
|
||||
import { EligibilityEvaluator, RatingData } from './EligibilityEvaluator';
|
||||
import { EligibilityFilterDto } from '../../application/dtos/EligibilityFilterDto';
|
||||
|
||||
describe('EligibilityEvaluator', () => {
|
||||
let evaluator: EligibilityEvaluator;
|
||||
|
||||
beforeEach(() => {
|
||||
evaluator = new EligibilityEvaluator();
|
||||
});
|
||||
|
||||
describe('DSL Parsing', () => {
|
||||
it('should parse simple platform condition', () => {
|
||||
const result = evaluator.parseDSL('platform.driving >= 55');
|
||||
|
||||
expect(result.logicalOperator).toBe('AND');
|
||||
expect(result.conditions).toHaveLength(1);
|
||||
expect(result.conditions[0]).toEqual({
|
||||
target: 'platform',
|
||||
dimension: 'driving',
|
||||
operator: '>=',
|
||||
expected: 55,
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse simple external condition', () => {
|
||||
const result = evaluator.parseDSL('external.iracing.iRating between 2000 2500');
|
||||
|
||||
expect(result.conditions).toHaveLength(1);
|
||||
expect(result.conditions[0]).toEqual({
|
||||
target: 'external',
|
||||
game: 'iracing',
|
||||
dimension: 'iRating',
|
||||
operator: 'between',
|
||||
expected: [2000, 2500],
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse AND conditions', () => {
|
||||
const result = evaluator.parseDSL('platform.driving >= 55 AND external.iracing.iRating >= 2000');
|
||||
|
||||
expect(result.logicalOperator).toBe('AND');
|
||||
expect(result.conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should parse OR conditions', () => {
|
||||
const result = evaluator.parseDSL('platform.driving >= 55 OR external.iracing.iRating between 2000 2500');
|
||||
|
||||
expect(result.logicalOperator).toBe('OR');
|
||||
expect(result.conditions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle all comparison operators', () => {
|
||||
const operators = ['>=', '<=', '>', '<', '=', '!='];
|
||||
|
||||
operators.forEach(op => {
|
||||
const result = evaluator.parseDSL(`platform.driving ${op} 55`);
|
||||
const condition = result.conditions[0];
|
||||
expect(condition).toBeDefined();
|
||||
if (condition) {
|
||||
expect(condition.operator).toBe(op);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw on invalid format', () => {
|
||||
expect(() => evaluator.parseDSL('invalid format')).toThrow();
|
||||
});
|
||||
|
||||
it('should throw on mixed AND/OR', () => {
|
||||
expect(() => evaluator.parseDSL('a >= 1 AND b >= 2 OR c >= 3')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Evaluation', () => {
|
||||
const ratingData: RatingData = {
|
||||
platform: {
|
||||
driving: 65,
|
||||
admin: 70,
|
||||
trust: 80,
|
||||
},
|
||||
external: {
|
||||
iracing: {
|
||||
iRating: 2200,
|
||||
safetyRating: 4.5,
|
||||
},
|
||||
assetto: {
|
||||
rating: 85,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('should evaluate simple platform condition - pass', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 55',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0].failed).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate simple platform condition - fail', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 75',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons[0].failed).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate external condition with between - pass', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'external.iracing.iRating between 2000 2500',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons[0].failed).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate external condition with between - fail', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'external.iracing.iRating between 2500 3000',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons[0].failed).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate AND conditions - all pass', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 2000',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
expect(result.reasons.every(r => !r.failed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate AND conditions - one fails', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 3000',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons.some(r => r.failed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate OR conditions - at least one passes', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 2000',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons.filter(r => !r.failed).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should evaluate OR conditions - all fail', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 3000',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons.every(r => r.failed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing data gracefully', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'external.missing.value >= 100',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons[0].failed).toBe(true);
|
||||
expect(result.reasons[0].message).toContain('Missing data');
|
||||
});
|
||||
|
||||
it('should include metadata with userId', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 55',
|
||||
context: { userId: 'user-123' },
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.metadata?.userId).toBe('user-123');
|
||||
expect(result.metadata?.filter).toBe('platform.driving >= 55');
|
||||
});
|
||||
|
||||
it('should provide explainable reasons', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'platform.driving >= 75',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.reasons[0]).toMatchObject({
|
||||
target: 'platform.driving',
|
||||
operator: '>=',
|
||||
expected: 75,
|
||||
actual: 65,
|
||||
failed: true,
|
||||
message: expect.stringContaining('Expected platform.driving >= 75, but got 65'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle all operators correctly', () => {
|
||||
const testCases = [
|
||||
{ dsl: 'platform.driving >= 60', expected: true },
|
||||
{ dsl: 'platform.driving > 65', expected: false },
|
||||
{ dsl: 'platform.driving <= 70', expected: true },
|
||||
{ dsl: 'platform.driving < 65', expected: false },
|
||||
{ dsl: 'platform.driving = 65', expected: true },
|
||||
{ dsl: 'platform.driving != 65', expected: false },
|
||||
];
|
||||
|
||||
testCases.forEach(({ dsl, expected }) => {
|
||||
const result = evaluator.evaluate({ dsl }, ratingData);
|
||||
expect(result.eligible).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle decimal values', () => {
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: 'external.iracing.safetyRating >= 4.0',
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate(filter, ratingData);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons[0].actual).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Summary Generation', () => {
|
||||
const ratingData: RatingData = {
|
||||
platform: { driving: 65 },
|
||||
external: { iracing: { iRating: 2200 } },
|
||||
};
|
||||
|
||||
it('should generate summary for AND all pass', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 2000' },
|
||||
ratingData
|
||||
);
|
||||
|
||||
expect(result.summary).toBe('Eligible: All conditions met (2/2)');
|
||||
});
|
||||
|
||||
it('should generate summary for OR at least one pass', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 2000' },
|
||||
ratingData
|
||||
);
|
||||
|
||||
expect(result.summary).toContain('Eligible: At least one condition met');
|
||||
});
|
||||
|
||||
it('should generate summary for AND with failures', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 3000' },
|
||||
ratingData
|
||||
);
|
||||
|
||||
expect(result.summary).toContain('Not eligible: 1 condition(s) failed');
|
||||
});
|
||||
|
||||
it('should generate summary for OR all fail', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 3000' },
|
||||
ratingData
|
||||
);
|
||||
|
||||
expect(result.summary).toContain('Not eligible: All conditions failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle parsing errors gracefully', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: 'invalid syntax here' },
|
||||
{ platform: {}, external: {} }
|
||||
);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons).toHaveLength(0);
|
||||
expect(result.summary).toContain('Failed to evaluate filter');
|
||||
expect(result.metadata?.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty DSL', () => {
|
||||
const result = evaluator.evaluate(
|
||||
{ dsl: '' },
|
||||
{ platform: {}, external: {} }
|
||||
);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal file
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Service: EligibilityEvaluator
|
||||
*
|
||||
* Pure domain service for DSL-based eligibility evaluation.
|
||||
* Parses DSL expressions and evaluates them against rating data.
|
||||
* Provides explainable results with detailed reasons.
|
||||
*/
|
||||
|
||||
import { EvaluationResultDto, EvaluationReason } from '../../application/dtos/EvaluationResultDto';
|
||||
import { EligibilityFilterDto, ParsedEligibilityFilter, EligibilityCondition } from '../../application/dtos/EligibilityFilterDto';
|
||||
|
||||
export interface RatingData {
|
||||
platform: {
|
||||
[dimension: string]: number;
|
||||
};
|
||||
external: {
|
||||
[game: string]: {
|
||||
[type: string]: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export class EligibilityEvaluator {
|
||||
/**
|
||||
* Main entry point: evaluate DSL against rating data
|
||||
*/
|
||||
evaluate(filter: EligibilityFilterDto, ratingData: RatingData): EvaluationResultDto {
|
||||
try {
|
||||
const parsed = this.parseDSL(filter.dsl);
|
||||
const reasons: EvaluationReason[] = [];
|
||||
|
||||
// Evaluate each condition
|
||||
for (const condition of parsed.conditions) {
|
||||
const reason = this.evaluateCondition(condition, ratingData);
|
||||
reasons.push(reason);
|
||||
}
|
||||
|
||||
// Determine overall eligibility based on logical operator
|
||||
const eligible = parsed.logicalOperator === 'AND'
|
||||
? reasons.every(r => !r.failed)
|
||||
: reasons.some(r => !r.failed);
|
||||
|
||||
// Build summary
|
||||
const summary = this.buildSummary(eligible, reasons, parsed.logicalOperator);
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
filter: filter.dsl,
|
||||
};
|
||||
|
||||
if (filter.context?.userId) {
|
||||
metadata.userId = filter.context.userId;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible,
|
||||
reasons,
|
||||
summary,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
metadata,
|
||||
};
|
||||
} catch (error) {
|
||||
// Handle parsing errors
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
|
||||
|
||||
const metadata: Record<string, unknown> = {
|
||||
filter: filter.dsl,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
||||
if (filter.context?.userId) {
|
||||
metadata.userId = filter.context.userId;
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: false,
|
||||
reasons: [],
|
||||
summary: `Failed to evaluate filter: ${errorMessage}`,
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse DSL string into structured conditions
|
||||
* Supports: platform.{dim} >= 55 OR external.{game}.{type} between 2000 2500
|
||||
*/
|
||||
parseDSL(dsl: string): ParsedEligibilityFilter {
|
||||
// Normalize and tokenize
|
||||
const normalized = dsl.trim().replace(/\s+/g, ' ');
|
||||
|
||||
// Determine logical operator
|
||||
const hasOR = normalized.toUpperCase().includes(' OR ');
|
||||
const hasAND = normalized.toUpperCase().includes(' AND ');
|
||||
|
||||
if (hasOR && hasAND) {
|
||||
throw new Error('Mixed AND/OR not supported. Use parentheses or separate filters.');
|
||||
}
|
||||
|
||||
const logicalOperator = hasOR ? 'OR' : 'AND';
|
||||
const separator = hasOR ? ' OR ' : ' AND ';
|
||||
|
||||
// Split into individual conditions
|
||||
const conditionStrings = normalized.split(separator).map(s => s.trim());
|
||||
|
||||
const conditions: EligibilityCondition[] = conditionStrings.map(str => {
|
||||
return this.parseCondition(str);
|
||||
});
|
||||
|
||||
return {
|
||||
conditions,
|
||||
logicalOperator,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single condition string
|
||||
* Examples:
|
||||
* - "platform.driving >= 55"
|
||||
* - "external.iracing.iRating between 2000 2500"
|
||||
*/
|
||||
parseCondition(conditionStr: string): EligibilityCondition {
|
||||
// Check for "between" operator
|
||||
const betweenMatch = conditionStr.match(/^(.+?)\s+between\s+(\d+)\s+(\d+)$/i);
|
||||
if (betweenMatch) {
|
||||
const targetExpr = betweenMatch[1]?.trim();
|
||||
const minStr = betweenMatch[2];
|
||||
const maxStr = betweenMatch[3];
|
||||
|
||||
if (!targetExpr || !minStr || !maxStr) {
|
||||
throw new Error(`Invalid between condition: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
const parsed = this.parseTargetExpression(targetExpr);
|
||||
|
||||
return {
|
||||
target: parsed.target,
|
||||
dimension: parsed.dimension,
|
||||
game: parsed.game,
|
||||
operator: 'between',
|
||||
expected: [parseFloat(minStr), parseFloat(maxStr)],
|
||||
} as unknown as EligibilityCondition;
|
||||
}
|
||||
|
||||
// Check for comparison operators
|
||||
const compareMatch = conditionStr.match(/^(.+?)\s*(>=|<=|>|<|=|!=)\s*(\d+(?:\.\d+)?)$/);
|
||||
if (compareMatch) {
|
||||
const targetExpr = compareMatch[1]?.trim();
|
||||
const operator = compareMatch[2];
|
||||
const valueStr = compareMatch[3];
|
||||
|
||||
if (!targetExpr || !operator || !valueStr) {
|
||||
throw new Error(`Invalid comparison condition: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
const parsed = this.parseTargetExpression(targetExpr);
|
||||
|
||||
return {
|
||||
target: parsed.target,
|
||||
dimension: parsed.dimension,
|
||||
game: parsed.game,
|
||||
operator: operator as EligibilityCondition['operator'],
|
||||
expected: parseFloat(valueStr),
|
||||
} as unknown as EligibilityCondition;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid condition format: "${conditionStr}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse target expression like "platform.driving" or "external.iracing.iRating"
|
||||
*/
|
||||
private parseTargetExpression(expr: string): { target: 'platform' | 'external'; dimension?: string; game?: string } {
|
||||
const parts = expr.split('.');
|
||||
|
||||
if (parts[0] === 'platform') {
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Invalid platform expression: "${expr}"`);
|
||||
}
|
||||
const dimension = parts[1];
|
||||
if (!dimension) {
|
||||
throw new Error(`Invalid platform expression: "${expr}"`);
|
||||
}
|
||||
return { target: 'platform', dimension };
|
||||
}
|
||||
|
||||
if (parts[0] === 'external') {
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid external expression: "${expr}"`);
|
||||
}
|
||||
const game = parts[1];
|
||||
const dimension = parts[2];
|
||||
if (!game || !dimension) {
|
||||
throw new Error(`Invalid external expression: "${expr}"`);
|
||||
}
|
||||
return { target: 'external', game, dimension };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown target: "${parts[0]}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single condition against rating data
|
||||
*/
|
||||
private evaluateCondition(condition: EligibilityCondition, ratingData: RatingData): EvaluationReason {
|
||||
// Get actual value
|
||||
let actual: number | undefined;
|
||||
|
||||
if (condition.target === 'platform' && condition.dimension) {
|
||||
actual = ratingData.platform[condition.dimension];
|
||||
} else if (condition.target === 'external' && condition.game && condition.dimension) {
|
||||
actual = ratingData.external[condition.game]?.[condition.dimension];
|
||||
}
|
||||
|
||||
// Handle missing data
|
||||
if (actual === undefined || actual === null || isNaN(actual)) {
|
||||
return {
|
||||
target: condition.target === 'platform'
|
||||
? `platform.${condition.dimension}`
|
||||
: `external.${condition.game}.${condition.dimension}`,
|
||||
operator: condition.operator,
|
||||
expected: condition.expected,
|
||||
actual: 0,
|
||||
failed: true,
|
||||
message: `Missing data for ${condition.target === 'platform' ? `platform dimension "${condition.dimension}"` : `external game "${condition.game}" type "${condition.dimension}"`}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Evaluate based on operator
|
||||
let failed = false;
|
||||
|
||||
switch (condition.operator) {
|
||||
case '>=':
|
||||
failed = actual < (condition.expected as number);
|
||||
break;
|
||||
case '<=':
|
||||
failed = actual > (condition.expected as number);
|
||||
break;
|
||||
case '>':
|
||||
failed = actual <= (condition.expected as number);
|
||||
break;
|
||||
case '<':
|
||||
failed = actual >= (condition.expected as number);
|
||||
break;
|
||||
case '=':
|
||||
failed = actual !== (condition.expected as number);
|
||||
break;
|
||||
case '!=':
|
||||
failed = actual === (condition.expected as number);
|
||||
break;
|
||||
case 'between': {
|
||||
const [min, max] = condition.expected as [number, number];
|
||||
failed = actual < min || actual > max;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown operator: ${condition.operator}`);
|
||||
}
|
||||
|
||||
const targetStr = condition.target === 'platform'
|
||||
? `platform.${condition.dimension}`
|
||||
: `external.${condition.game}.${condition.dimension}`;
|
||||
|
||||
const expectedStr = condition.operator === 'between'
|
||||
? `${(condition.expected as [number, number])[0]} to ${(condition.expected as [number, number])[1]}`
|
||||
: `${condition.operator} ${condition.expected}`;
|
||||
|
||||
return {
|
||||
target: targetStr,
|
||||
operator: condition.operator,
|
||||
expected: condition.expected,
|
||||
actual,
|
||||
failed,
|
||||
message: failed
|
||||
? `Expected ${targetStr} ${expectedStr}, but got ${actual}`
|
||||
: `${targetStr} ${expectedStr} ✓`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build human-readable summary
|
||||
*/
|
||||
private buildSummary(eligible: boolean, reasons: EvaluationReason[], operator: 'AND' | 'OR'): string {
|
||||
const failedReasons = reasons.filter(r => r.failed);
|
||||
|
||||
if (eligible) {
|
||||
if (operator === 'OR') {
|
||||
return `Eligible: At least one condition met (${reasons.filter(r => !r.failed).length}/${reasons.length})`;
|
||||
}
|
||||
return `Eligible: All conditions met (${reasons.length}/${reasons.length})`;
|
||||
}
|
||||
|
||||
if (operator === 'AND') {
|
||||
return `Not eligible: ${failedReasons.length} condition(s) failed`;
|
||||
}
|
||||
|
||||
return `Not eligible: All conditions failed (${failedReasons.length}/${reasons.length})`;
|
||||
}
|
||||
}
|
||||
489
core/identity/domain/services/RatingEventFactory.test.ts
Normal file
489
core/identity/domain/services/RatingEventFactory.test.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { RatingEventFactory, RaceFactsDto } from './RatingEventFactory';
|
||||
|
||||
describe('RatingEventFactory', () => {
|
||||
describe('createFromRaceFinish', () => {
|
||||
it('should create events from race finish data', () => {
|
||||
const events = RatingEventFactory.createFromRaceFinish({
|
||||
userId: 'user-123',
|
||||
raceId: 'race-456',
|
||||
position: 3,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 1,
|
||||
fieldStrength: 2500,
|
||||
status: 'finished',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.userId).toBe('user-123');
|
||||
expect(event.source.type).toBe('race');
|
||||
expect(event.source.id).toBe('race-456');
|
||||
}
|
||||
});
|
||||
|
||||
it('should create events for DNS status', () => {
|
||||
const events = RatingEventFactory.createFromRaceFinish({
|
||||
userId: 'user-123',
|
||||
raceId: 'race-456',
|
||||
position: 10,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 0,
|
||||
fieldStrength: 2500,
|
||||
status: 'dns',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnsEvent = events.find(e => e.reason.code.includes('DNS'));
|
||||
expect(dnsEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create events for DNF status', () => {
|
||||
const events = RatingEventFactory.createFromRaceFinish({
|
||||
userId: 'user-123',
|
||||
raceId: 'race-456',
|
||||
position: 10,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 2,
|
||||
fieldStrength: 2500,
|
||||
status: 'dnf',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dnfEvent = events.find(e => e.reason.code.includes('DNF'));
|
||||
expect(dnfEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create events for DSQ status', () => {
|
||||
const events = RatingEventFactory.createFromRaceFinish({
|
||||
userId: 'user-123',
|
||||
raceId: 'race-456',
|
||||
position: 10,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 5,
|
||||
fieldStrength: 2500,
|
||||
status: 'dsq',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const dsqEvent = events.find(e => e.reason.code.includes('DSQ'));
|
||||
expect(dsqEvent).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create events for AFK status', () => {
|
||||
const events = RatingEventFactory.createFromRaceFinish({
|
||||
userId: 'user-123',
|
||||
raceId: 'race-456',
|
||||
position: 10,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 0,
|
||||
fieldStrength: 2500,
|
||||
status: 'afk',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const afkEvent = events.find(e => e.reason.code.includes('AFK'));
|
||||
expect(afkEvent).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDrivingEventsFromRace', () => {
|
||||
it('should create events for single driver with good performance', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByUser.has('user-123')).toBe(true);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
const performanceEvent = events.find(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN');
|
||||
expect(performanceEvent).toBeDefined();
|
||||
expect(performanceEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should create events for multiple drivers', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 2,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-789',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByUser.has('user-123')).toBe(true);
|
||||
expect(eventsByUser.has('user-456')).toBe(true);
|
||||
expect(eventsByUser.has('user-789')).toBe(true);
|
||||
|
||||
// user-123 should have performance events
|
||||
const user123Events = eventsByUser.get('user-123')!;
|
||||
expect(user123Events.some(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN')).toBe(true);
|
||||
|
||||
// user-456 should have incident penalty
|
||||
const user456Events = eventsByUser.get('user-456')!;
|
||||
expect(user456Events.some(e => e.reason.code === 'DRIVING_INCIDENTS_PENALTY')).toBe(true);
|
||||
|
||||
// user-789 should have DNS penalty
|
||||
const user789Events = eventsByUser.get('user-789')!;
|
||||
expect(user789Events.some(e => e.reason.code === 'DRIVING_DNS_PENALTY')).toBe(true);
|
||||
});
|
||||
|
||||
it('should create positions gained bonus', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 10,
|
||||
finishPos: 3,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
|
||||
const gainEvent = events.find(e => e.reason.code === 'DRIVING_POSITIONS_GAINED_BONUS');
|
||||
expect(gainEvent).toBeDefined();
|
||||
expect(gainEvent?.delta.value).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle DNF status', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dnf',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
|
||||
const dnfEvent = events.find(e => e.reason.code === 'DRIVING_DNF_PENALTY');
|
||||
expect(dnfEvent).toBeDefined();
|
||||
expect(dnfEvent?.delta.value).toBe(-10);
|
||||
});
|
||||
|
||||
it('should handle DSQ status', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dsq',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
|
||||
const dsqEvent = events.find(e => e.reason.code === 'DRIVING_DSQ_PENALTY');
|
||||
expect(dsqEvent).toBeDefined();
|
||||
expect(dsqEvent?.delta.value).toBe(-25);
|
||||
});
|
||||
|
||||
it('should handle AFK status', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'afk',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
|
||||
const afkEvent = events.find(e => e.reason.code === 'DRIVING_AFK_PENALTY');
|
||||
expect(afkEvent).toBeDefined();
|
||||
expect(afkEvent?.delta.value).toBe(-20);
|
||||
});
|
||||
|
||||
it('should handle incident penalties', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 3,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
const events = eventsByUser.get('user-123')!;
|
||||
|
||||
const incidentEvent = events.find(e => e.reason.code === 'DRIVING_INCIDENTS_PENALTY');
|
||||
expect(incidentEvent).toBeDefined();
|
||||
expect(incidentEvent?.delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should calculate SoF if not provided', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
// Should still work without errors
|
||||
expect(eventsByUser.size).toBe(2);
|
||||
expect(eventsByUser.get('user-123')!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle empty results', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
expect(eventsByUser.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle mixed statuses', () => {
|
||||
const raceFacts: RaceFactsDto = {
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 10,
|
||||
incidents: 0,
|
||||
status: 'dnf',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-789',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
|
||||
|
||||
expect(eventsByUser.size).toBe(3);
|
||||
|
||||
// user-123: performance + incidents
|
||||
const user123Events = eventsByUser.get('user-123')!;
|
||||
expect(user123Events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// user-456: DNF
|
||||
const user456Events = eventsByUser.get('user-456')!;
|
||||
expect(user456Events.some(e => e.reason.code === 'DRIVING_DNF_PENALTY')).toBe(true);
|
||||
|
||||
// user-789: DNS
|
||||
const user789Events = eventsByUser.get('user-789')!;
|
||||
expect(user789Events.some(e => e.reason.code === 'DRIVING_DNS_PENALTY')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromPenalty', () => {
|
||||
it('should create driving penalty event', () => {
|
||||
const events = RatingEventFactory.createFromPenalty({
|
||||
userId: 'user-123',
|
||||
penaltyId: 'penalty-789',
|
||||
penaltyType: 'incident',
|
||||
severity: 'major',
|
||||
reason: 'Caused collision',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.source.type).toBe('penalty');
|
||||
expect(event.source.id).toBe('penalty-789');
|
||||
}
|
||||
});
|
||||
|
||||
it('should create admin trust penalty event', () => {
|
||||
const events = RatingEventFactory.createFromPenalty({
|
||||
userId: 'user-123',
|
||||
penaltyId: 'penalty-789',
|
||||
penaltyType: 'admin_violation',
|
||||
severity: 'major',
|
||||
reason: 'Abuse of power',
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromVote', () => {
|
||||
it('should create positive vote event', () => {
|
||||
const events = RatingEventFactory.createFromVote({
|
||||
userId: 'user-123',
|
||||
voteSessionId: 'vote-101',
|
||||
outcome: 'positive',
|
||||
voteCount: 8,
|
||||
eligibleVoterCount: 10,
|
||||
percentPositive: 80,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
expect(event.delta.value).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create negative vote event', () => {
|
||||
const events = RatingEventFactory.createFromVote({
|
||||
userId: 'user-123',
|
||||
voteSessionId: 'vote-101',
|
||||
outcome: 'negative',
|
||||
voteCount: 2,
|
||||
eligibleVoterCount: 10,
|
||||
percentPositive: 20,
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.delta.value).toBeLessThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromAdminAction', () => {
|
||||
it('should create admin action bonus event', () => {
|
||||
const events = RatingEventFactory.createFromAdminAction({
|
||||
userId: 'user-123',
|
||||
adminActionId: 'admin-202',
|
||||
actionType: 'sla_response',
|
||||
details: { responseTime: 30 },
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
expect(event.delta.value).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create admin action penalty event', () => {
|
||||
const events = RatingEventFactory.createFromAdminAction({
|
||||
userId: 'user-123',
|
||||
adminActionId: 'admin-202',
|
||||
actionType: 'abuse_report',
|
||||
details: { validated: true },
|
||||
});
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
const event = events[0];
|
||||
expect(event).toBeDefined();
|
||||
if (event) {
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
expect(event.delta.value).toBeLessThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
655
core/identity/domain/services/RatingEventFactory.ts
Normal file
655
core/identity/domain/services/RatingEventFactory.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
|
||||
import { AdminTrustReasonCode } from '../value-objects/AdminTrustReasonCode';
|
||||
|
||||
// Existing interfaces
|
||||
interface RaceFinishInput {
|
||||
userId: string;
|
||||
raceId: string;
|
||||
position: number;
|
||||
totalDrivers: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
fieldStrength: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
}
|
||||
|
||||
interface PenaltyInput {
|
||||
userId: string;
|
||||
penaltyId: string;
|
||||
penaltyType: 'incident' | 'admin_violation';
|
||||
severity: 'minor' | 'major';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface VoteInput {
|
||||
userId: string;
|
||||
voteSessionId: string;
|
||||
outcome: 'positive' | 'negative';
|
||||
voteCount: number;
|
||||
eligibleVoterCount: number;
|
||||
percentPositive: number;
|
||||
}
|
||||
|
||||
interface AdminActionInput {
|
||||
userId: string;
|
||||
adminActionId: string;
|
||||
actionType: 'sla_response' | 'abuse_report' | 'rule_clarity';
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// NEW: Enhanced interface for race facts (per plans section 5.1.2)
|
||||
export interface RaceFactsDto {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number; // Optional strength of field
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain Service: RatingEventFactory
|
||||
*
|
||||
* Pure, stateless factory that turns domain facts into rating events.
|
||||
* Follows the pattern of creating immutable entities from business facts.
|
||||
* Enhanced to support full driving event taxonomy from plans.
|
||||
*/
|
||||
export class RatingEventFactory {
|
||||
/**
|
||||
* Create rating events from race finish data
|
||||
* Handles performance, clean driving, and reliability dimensions
|
||||
*/
|
||||
static createFromRaceFinish(input: RaceFinishInput): RatingEvent[] {
|
||||
const events: RatingEvent[] = [];
|
||||
const now = new Date();
|
||||
|
||||
// Performance events (only for finished races)
|
||||
if (input.status === 'finished') {
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
input.position,
|
||||
input.totalDrivers,
|
||||
input.startPosition,
|
||||
input.fieldStrength
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||
summary: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in field of ${input.totalDrivers}`,
|
||||
details: {
|
||||
position: input.position,
|
||||
totalDrivers: input.totalDrivers,
|
||||
startPosition: input.startPosition,
|
||||
fieldStrength: input.fieldStrength,
|
||||
positionsGained: input.startPosition - input.position,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Positions gained bonus
|
||||
const positionsGained = input.startPosition - input.position;
|
||||
if (positionsGained > 0) {
|
||||
const gainBonus = Math.min(positionsGained * 2, 10); // Max 10 points
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_POSITIONS_GAINED_BONUS',
|
||||
summary: `Gained ${positionsGained} positions`,
|
||||
details: { positionsGained },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean driving penalty (incidents)
|
||||
if (input.incidents > 0) {
|
||||
const incidentPenalty = Math.min(input.incidents * 5, 30); // Max 30 points penalty
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_INCIDENTS_PENALTY',
|
||||
summary: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
|
||||
details: { incidents: input.incidents },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Reliability penalties
|
||||
if (input.status === 'dns') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DNS_PENALTY',
|
||||
summary: 'Did not start',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dnf') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-10),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DNF_PENALTY',
|
||||
summary: 'Did not finish',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'dsq') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-25),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_DSQ_PENALTY',
|
||||
summary: 'Disqualified',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.status === 'afk') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-20),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: input.raceId },
|
||||
reason: {
|
||||
code: 'DRIVING_AFK_PENALTY',
|
||||
summary: 'AFK / Not responsive',
|
||||
details: {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: Create rating events from race facts DTO
|
||||
* Supports multiple drivers and full event taxonomy
|
||||
* Returns a map of userId to events for efficient processing
|
||||
*/
|
||||
static createDrivingEventsFromRace(raceFacts: RaceFactsDto): Map<string, RatingEvent[]> {
|
||||
const eventsByUser = new Map<string, RatingEvent[]>();
|
||||
const now = new Date();
|
||||
|
||||
// Calculate field strength if not provided in all results
|
||||
const hasSof = raceFacts.results.some(r => r.sof !== undefined);
|
||||
let fieldStrength = 0;
|
||||
|
||||
if (hasSof) {
|
||||
const sofResults = raceFacts.results.filter(r => r.sof !== undefined);
|
||||
fieldStrength = sofResults.reduce((sum, r) => sum + r.sof!, 0) / sofResults.length;
|
||||
} else {
|
||||
// Use average of finished positions as proxy
|
||||
const finishedResults = raceFacts.results.filter(r => r.status === 'finished');
|
||||
if (finishedResults.length > 0) {
|
||||
fieldStrength = finishedResults.reduce((sum, r) => sum + (r.finishPos * 100), 0) / finishedResults.length;
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of raceFacts.results) {
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
// 1. Performance events (only for finished races)
|
||||
if (result.status === 'finished') {
|
||||
const performanceDelta = this.calculatePerformanceDelta(
|
||||
result.finishPos,
|
||||
raceFacts.results.length,
|
||||
result.startPos,
|
||||
fieldStrength
|
||||
);
|
||||
|
||||
if (performanceDelta !== 0) {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(performanceDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN').value,
|
||||
summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${raceFacts.results.length}`,
|
||||
details: {
|
||||
startPos: result.startPos,
|
||||
finishPos: result.finishPos,
|
||||
positionsGained: result.startPos - result.finishPos,
|
||||
fieldStrength: fieldStrength,
|
||||
totalDrivers: raceFacts.results.length,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Positions gained bonus
|
||||
const positionsGained = result.startPos - result.finishPos;
|
||||
if (positionsGained > 0) {
|
||||
const gainBonus = Math.min(positionsGained * 2, 10);
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(gainBonus),
|
||||
weight: 0.5,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_POSITIONS_GAINED_BONUS').value,
|
||||
summary: `Gained ${positionsGained} positions`,
|
||||
details: { positionsGained },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clean driving penalty (incidents)
|
||||
if (result.incidents > 0) {
|
||||
const incidentPenalty = Math.min(result.incidents * 5, 30);
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(-incidentPenalty),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY').value,
|
||||
summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
|
||||
details: { incidents: result.incidents },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Reliability penalties
|
||||
if (result.status !== 'finished') {
|
||||
let penaltyDelta = 0;
|
||||
let reasonCode: DrivingReasonCodeValue;
|
||||
|
||||
switch (result.status) {
|
||||
case 'dns':
|
||||
penaltyDelta = -15;
|
||||
reasonCode = 'DRIVING_DNS_PENALTY';
|
||||
break;
|
||||
case 'dnf':
|
||||
penaltyDelta = -10;
|
||||
reasonCode = 'DRIVING_DNF_PENALTY';
|
||||
break;
|
||||
case 'dsq':
|
||||
penaltyDelta = -25;
|
||||
reasonCode = 'DRIVING_DSQ_PENALTY';
|
||||
break;
|
||||
case 'afk':
|
||||
penaltyDelta = -20;
|
||||
reasonCode = 'DRIVING_AFK_PENALTY';
|
||||
break;
|
||||
default:
|
||||
continue; // Skip unknown statuses
|
||||
}
|
||||
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: result.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(penaltyDelta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'race', id: raceFacts.raceId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create(reasonCode).value,
|
||||
summary: this.getStatusSummary(result.status),
|
||||
details: { status: result.status },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length > 0) {
|
||||
eventsByUser.set(result.userId, events);
|
||||
}
|
||||
}
|
||||
|
||||
return eventsByUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from penalty data
|
||||
*/
|
||||
static createFromPenalty(input: PenaltyInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
if (input.penaltyType === 'incident') {
|
||||
const delta = input.severity === 'major' ? -15 : -5;
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyId },
|
||||
reason: {
|
||||
code: DrivingReasonCode.create('DRIVING_PENALTY_INVOLVEMENT_PENALTY').value,
|
||||
summary: input.reason,
|
||||
details: { severity: input.severity, type: input.penaltyType },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.penaltyType === 'admin_violation') {
|
||||
const delta = input.severity === 'major' ? -20 : -10;
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'penalty', id: input.penaltyId },
|
||||
reason: {
|
||||
code: 'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||
summary: input.reason,
|
||||
details: { severity: input.severity, type: input.penaltyType },
|
||||
},
|
||||
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from vote outcome
|
||||
*/
|
||||
static createFromVote(input: VoteInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
// Calculate delta based on vote outcome
|
||||
// Scale: -20 to +20 based on percentage
|
||||
let delta: number;
|
||||
if (input.outcome === 'positive') {
|
||||
delta = Math.round((input.percentPositive / 100) * 20); // 0 to +20
|
||||
} else {
|
||||
delta = -Math.round(((100 - input.percentPositive) / 100) * 20); // -20 to 0
|
||||
}
|
||||
|
||||
if (delta !== 0) {
|
||||
const reasonCode = input.outcome === 'positive'
|
||||
? AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE').value
|
||||
: AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE').value;
|
||||
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(delta),
|
||||
weight: input.voteCount, // Weight by number of votes
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'vote', id: input.voteSessionId },
|
||||
reason: {
|
||||
code: reasonCode,
|
||||
summary: `Vote outcome: ${input.percentPositive}% positive (${input.voteCount}/${input.eligibleVoterCount})`,
|
||||
details: {
|
||||
voteCount: input.voteCount,
|
||||
eligibleVoterCount: input.eligibleVoterCount,
|
||||
percentPositive: input.percentPositive,
|
||||
},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from admin action
|
||||
*/
|
||||
static createFromAdminAction(input: AdminActionInput): RatingEvent[] {
|
||||
const now = new Date();
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
if (input.actionType === 'sla_response') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(5),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_SLA_BONUS').value,
|
||||
summary: 'Timely response to admin task',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'abuse_report') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(-15),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_ABUSE_REPORT_PENALTY').value,
|
||||
summary: 'Validated abuse report',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
} else if (input.actionType === 'rule_clarity') {
|
||||
events.push(
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: input.userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(3),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'adminAction', id: input.adminActionId },
|
||||
reason: {
|
||||
code: AdminTrustReasonCode.create('ADMIN_ACTION_RULE_CLARITY_BONUS').value,
|
||||
summary: 'Published clear rules/changes',
|
||||
details: input.details,
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance delta based on position and field strength
|
||||
*/
|
||||
private static calculatePerformanceDelta(
|
||||
position: number,
|
||||
totalDrivers: number,
|
||||
startPosition: number,
|
||||
fieldStrength: number
|
||||
): number {
|
||||
// Base score from position (reverse percentile)
|
||||
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||
|
||||
// Bonus for positions gained
|
||||
const positionsGained = startPosition - position;
|
||||
const gainBonus = Math.max(0, positionsGained * 2);
|
||||
|
||||
// Field strength multiplier (higher field strength = harder competition)
|
||||
// Normalize field strength to 0.8-1.2 range
|
||||
const fieldMultiplier = 0.8 + Math.min(fieldStrength / 10000, 0.4);
|
||||
|
||||
const rawScore = (positionScore + gainBonus) * fieldMultiplier;
|
||||
|
||||
// Convert to delta (range -50 to +50)
|
||||
// 50th percentile = 0, top = +50, bottom = -50
|
||||
return Math.round(rawScore - 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ordinal suffix for position
|
||||
*/
|
||||
private static getOrdinalSuffix(position: number): string {
|
||||
const j = position % 10;
|
||||
const k = position % 100;
|
||||
if (j === 1 && k !== 11) return 'st';
|
||||
if (j === 2 && k !== 12) return 'nd';
|
||||
if (j === 3 && k !== 13) return 'rd';
|
||||
return 'th';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable summary for status
|
||||
*/
|
||||
private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string {
|
||||
switch (status) {
|
||||
case 'finished': return 'Race completed';
|
||||
case 'dnf': return 'Did not finish';
|
||||
case 'dns': return 'Did not start';
|
||||
case 'dsq': return 'Disqualified';
|
||||
case 'afk': return 'AFK / Not responsive';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Type export for convenience
|
||||
export type DrivingReasonCodeValue =
|
||||
| 'DRIVING_FINISH_STRENGTH_GAIN'
|
||||
| 'DRIVING_POSITIONS_GAINED_BONUS'
|
||||
| 'DRIVING_PACE_RELATIVE_GAIN'
|
||||
| 'DRIVING_INCIDENTS_PENALTY'
|
||||
| 'DRIVING_MAJOR_CONTACT_PENALTY'
|
||||
| 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'
|
||||
| 'DRIVING_DNS_PENALTY'
|
||||
| 'DRIVING_DNF_PENALTY'
|
||||
| 'DRIVING_DSQ_PENALTY'
|
||||
| 'DRIVING_AFK_PENALTY'
|
||||
| 'DRIVING_SEASON_ATTENDANCE_BONUS';
|
||||
@@ -0,0 +1,77 @@
|
||||
import { RatingSnapshotCalculator } from './RatingSnapshotCalculator';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
describe('RatingSnapshotCalculator', () => {
|
||||
describe('calculate', () => {
|
||||
it('should return stub implementation with basic snapshot', () => {
|
||||
const userId = 'user-123';
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(10),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'TEST', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = RatingSnapshotCalculator.calculate(userId, events);
|
||||
|
||||
// Stub returns a UserRating with updated driver dimension
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.driver.value).toBeGreaterThan(50); // Should have increased
|
||||
expect(result.driver.sampleSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle empty events array', () => {
|
||||
const userId = 'user-123';
|
||||
const result = RatingSnapshotCalculator.calculate(userId, []);
|
||||
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.driver.sampleSize).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple dimensions', () => {
|
||||
const userId = 'user-123';
|
||||
const events: RatingEvent[] = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: userId,
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(10),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'TEST', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(5),
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'vote', id: 'vote-123' },
|
||||
reason: { code: 'TEST', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = RatingSnapshotCalculator.calculate(userId, events);
|
||||
|
||||
expect(result.driver.value).toBeGreaterThan(50);
|
||||
expect(result.admin.value).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
core/identity/domain/services/RatingSnapshotCalculator.ts
Normal file
56
core/identity/domain/services/RatingSnapshotCalculator.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { UserRating } from '../value-objects/UserRating';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
|
||||
/**
|
||||
* Domain Service: RatingSnapshotCalculator
|
||||
*
|
||||
* Pure, stateless calculator that derives a UserRating snapshot from events.
|
||||
* STUB IMPLEMENTATION - will be evolved in future slices.
|
||||
*/
|
||||
export class RatingSnapshotCalculator {
|
||||
/**
|
||||
* Calculate UserRating snapshot from events
|
||||
*
|
||||
* STUB: Currently creates a basic snapshot by summing deltas per dimension.
|
||||
* Future: Will implement:
|
||||
* - Confidence calculation based on sample size
|
||||
* - Trend detection from recent events
|
||||
* - Exponential moving averages
|
||||
* - Calculator version tracking
|
||||
*/
|
||||
static calculate(userId: string, events: RatingEvent[]): UserRating {
|
||||
// Start with default UserRating
|
||||
let snapshot = UserRating.create(userId);
|
||||
|
||||
// Group events by dimension
|
||||
const eventsByDimension = events.reduce((acc, event) => {
|
||||
const dimension = event.dimension.value;
|
||||
if (!acc[dimension]) acc[dimension] = [];
|
||||
acc[dimension].push(event);
|
||||
return acc;
|
||||
}, {} as Record<string, RatingEvent[]>);
|
||||
|
||||
// Apply events to each dimension
|
||||
for (const [dimension, dimensionEvents] of Object.entries(eventsByDimension)) {
|
||||
const totalDelta = dimensionEvents.reduce((sum, e) => sum + e.delta.value, 0);
|
||||
const sampleSize = dimensionEvents.length;
|
||||
|
||||
// Calculate new value (base 50 + delta)
|
||||
const newValue = Math.max(0, Math.min(100, 50 + totalDelta));
|
||||
|
||||
// Update the appropriate dimension
|
||||
if (dimension === 'driving') {
|
||||
snapshot = snapshot.updateDriverRating(newValue, sampleSize);
|
||||
} else if (dimension === 'adminTrust') {
|
||||
snapshot = snapshot.updateAdminRating(newValue, sampleSize);
|
||||
} else if (dimension === 'stewardTrust') {
|
||||
snapshot = snapshot.updateStewardRating(newValue, sampleSize);
|
||||
} else if (dimension === 'broadcasterTrust') {
|
||||
// Future dimension - would need to add to UserRating
|
||||
// For now, skip
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
301
core/identity/domain/services/RatingUpdateService.test.ts
Normal file
301
core/identity/domain/services/RatingUpdateService.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { RatingUpdateService } from './RatingUpdateService';
|
||||
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
|
||||
import type { IRatingEventRepository } from '../repositories/IRatingEventRepository';
|
||||
import { UserRating } from '../value-objects/UserRating';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
describe('RatingUpdateService - Slice 7 Evolution', () => {
|
||||
let service: RatingUpdateService;
|
||||
let userRatingRepository: any;
|
||||
let ratingEventRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
userRatingRepository = {
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
ratingEventRepository = {
|
||||
save: vi.fn(),
|
||||
getAllByUserId: vi.fn(),
|
||||
};
|
||||
|
||||
service = new RatingUpdateService(userRatingRepository, ratingEventRepository);
|
||||
});
|
||||
|
||||
describe('recordRaceRatingEvents - Ledger-based approach', () => {
|
||||
it('should record race events and update snapshots', async () => {
|
||||
const raceId = 'race-123';
|
||||
const raceResults = [
|
||||
{
|
||||
userId: 'driver-1',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 1,
|
||||
status: 'finished' as const,
|
||||
},
|
||||
{
|
||||
userId: 'driver-2',
|
||||
startPos: 3,
|
||||
finishPos: 1,
|
||||
incidents: 0,
|
||||
status: 'finished' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock repositories
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockImplementation((userId: string) => {
|
||||
// Return mock events based on user
|
||||
if (userId === 'driver-1') {
|
||||
return Promise.resolve([
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'driver-1',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(15),
|
||||
weight: 1,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: raceId },
|
||||
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'driver-2',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(20),
|
||||
weight: 1,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'race', id: raceId },
|
||||
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
const result = await service.recordRaceRatingEvents(raceId, raceResults);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.driversUpdated).toEqual(['driver-1', 'driver-2']);
|
||||
|
||||
// Verify events were saved
|
||||
expect(ratingEventRepository.save).toHaveBeenCalled();
|
||||
|
||||
// Verify snapshots were recomputed
|
||||
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-1');
|
||||
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-2');
|
||||
expect(userRatingRepository.save).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle empty race results gracefully', async () => {
|
||||
const result = await service.recordRaceRatingEvents('race-123', []);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.driversUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return failure on repository errors', async () => {
|
||||
ratingEventRepository.save.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await service.recordRaceRatingEvents('race-123', [
|
||||
{
|
||||
userId: 'driver-1',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 1,
|
||||
status: 'finished' as const,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.driversUpdated).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle DNF status correctly', async () => {
|
||||
const raceResults = [
|
||||
{
|
||||
userId: 'driver-1',
|
||||
startPos: 5,
|
||||
finishPos: 10,
|
||||
incidents: 2,
|
||||
status: 'dnf' as const,
|
||||
},
|
||||
];
|
||||
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
const result = await service.recordRaceRatingEvents('race-123', raceResults);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
|
||||
// Verify DNF penalty event was created
|
||||
const savedEvents = ratingEventRepository.save.mock.calls.map(call => call[0]);
|
||||
const hasDnfPenalty = savedEvents.some((event: any) =>
|
||||
event.reason.code === 'DRIVING_DNF_PENALTY'
|
||||
);
|
||||
expect(hasDnfPenalty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDriverRatingsAfterRace - Backward compatibility', () => {
|
||||
it('should delegate to new ledger-based approach', async () => {
|
||||
const driverResults = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 2,
|
||||
totalDrivers: 10,
|
||||
incidents: 1,
|
||||
startPosition: 5,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the new method to succeed
|
||||
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
|
||||
success: true,
|
||||
eventsCreated: 2,
|
||||
driversUpdated: ['driver-1'],
|
||||
});
|
||||
|
||||
await service.updateDriverRatingsAfterRace(driverResults);
|
||||
|
||||
expect(recordSpy).toHaveBeenCalled();
|
||||
|
||||
recordSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should throw error when ledger approach fails', async () => {
|
||||
const driverResults = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
position: 2,
|
||||
totalDrivers: 10,
|
||||
incidents: 1,
|
||||
startPosition: 5,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock the new method to fail
|
||||
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
|
||||
success: false,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
});
|
||||
|
||||
await expect(service.updateDriverRatingsAfterRace(driverResults)).rejects.toThrow(
|
||||
'Failed to update ratings via event system'
|
||||
);
|
||||
|
||||
recordSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTrustScore - Ledger-based', () => {
|
||||
it('should create trust event and update snapshot', async () => {
|
||||
const userId = 'user-1';
|
||||
const trustChange = 10;
|
||||
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockResolvedValue([
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: userId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'manualAdjustment', id: 'trust-test' },
|
||||
reason: { code: 'TRUST_BONUS', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
]);
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
await service.updateTrustScore(userId, trustChange);
|
||||
|
||||
expect(ratingEventRepository.save).toHaveBeenCalled();
|
||||
expect(userRatingRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle negative trust changes', async () => {
|
||||
const userId = 'user-1';
|
||||
const trustChange = -5;
|
||||
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
await service.updateTrustScore(userId, trustChange);
|
||||
|
||||
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
|
||||
expect(savedEvent.reason.code).toBe('TRUST_PENALTY');
|
||||
expect(savedEvent.delta.value).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStewardRating - Ledger-based', () => {
|
||||
it('should create steward event and update snapshot', async () => {
|
||||
const stewardId = 'steward-1';
|
||||
const ratingChange = 8;
|
||||
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockResolvedValue([
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: stewardId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(8),
|
||||
weight: 1,
|
||||
occurredAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: 'manualAdjustment', id: 'steward-test' },
|
||||
reason: { code: 'STEWARD_BONUS', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
]);
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
await service.updateStewardRating(stewardId, ratingChange);
|
||||
|
||||
expect(ratingEventRepository.save).toHaveBeenCalled();
|
||||
expect(userRatingRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle negative steward rating changes', async () => {
|
||||
const stewardId = 'steward-1';
|
||||
const ratingChange = -3;
|
||||
|
||||
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
|
||||
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
|
||||
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
|
||||
|
||||
await service.updateStewardRating(stewardId, ratingChange);
|
||||
|
||||
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
|
||||
expect(savedEvent.reason.code).toBe('STEWARD_PENALTY');
|
||||
expect(savedEvent.delta.value).toBe(-3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,88 @@
|
||||
import type { IDomainService } from '@core/shared/domain';
|
||||
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
|
||||
import { UserRating } from '../value-objects/UserRating';
|
||||
import type { IRatingEventRepository } from '../repositories/IRatingEventRepository';
|
||||
import { RatingEventFactory } from './RatingEventFactory';
|
||||
import { RatingSnapshotCalculator } from './RatingSnapshotCalculator';
|
||||
import { RatingEvent } from '../entities/RatingEvent';
|
||||
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||
|
||||
/**
|
||||
* Domain Service: RatingUpdateService
|
||||
*
|
||||
* Handles updating user ratings based on various events and performance metrics.
|
||||
* Centralizes rating calculation logic and ensures consistency across the system.
|
||||
*
|
||||
* EVOLVED (Slice 7): Now uses event-driven approach with ledger pattern.
|
||||
* Records rating events and recomputes snapshots for transparency and auditability.
|
||||
*/
|
||||
export class RatingUpdateService implements IDomainService {
|
||||
readonly serviceName = 'RatingUpdateService';
|
||||
|
||||
constructor(
|
||||
private readonly userRatingRepository: IUserRatingRepository
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
private readonly ratingEventRepository: IRatingEventRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Update driver ratings after race completion
|
||||
* Record race rating events and update snapshots (NEW LEDGER APPROACH)
|
||||
* Replaces direct rating updates with event recording + snapshot recomputation
|
||||
*/
|
||||
async recordRaceRatingEvents(raceId: string, raceResults: Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number;
|
||||
}>): Promise<{ success: boolean; eventsCreated: number; driversUpdated: string[] }> {
|
||||
try {
|
||||
// Use factory to create rating events from race results
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({
|
||||
raceId,
|
||||
results: raceResults,
|
||||
});
|
||||
|
||||
let totalEvents = 0;
|
||||
const driversUpdated: string[] = [];
|
||||
|
||||
// Process each user's events
|
||||
for (const [userId, events] of eventsByUser) {
|
||||
if (events.length === 0) continue;
|
||||
|
||||
// Save all events to ledger
|
||||
for (const event of events) {
|
||||
await this.ratingEventRepository.save(event);
|
||||
totalEvents++;
|
||||
}
|
||||
|
||||
// Recompute snapshot from all events for this user
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(userId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(userId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
|
||||
driversUpdated.push(userId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
eventsCreated: totalEvents,
|
||||
driversUpdated,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[RatingUpdateService] Failed to record race rating events:', error);
|
||||
return {
|
||||
success: false,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update driver ratings after race completion (BACKWARD COMPATIBLE)
|
||||
* Still supported but now delegates to event-based approach internally
|
||||
*/
|
||||
async updateDriverRatingsAfterRace(
|
||||
driverResults: Array<{
|
||||
@@ -27,13 +93,28 @@ export class RatingUpdateService implements IDomainService {
|
||||
startPosition: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
for (const result of driverResults) {
|
||||
await this.updateDriverRating(result);
|
||||
// Convert to new format and use event-based approach
|
||||
const raceResults = driverResults.map(result => ({
|
||||
userId: result.driverId,
|
||||
startPos: result.startPosition,
|
||||
finishPos: result.position,
|
||||
incidents: result.incidents,
|
||||
status: 'finished' as const,
|
||||
}));
|
||||
|
||||
// Generate a synthetic race ID for backward compatibility
|
||||
const raceId = `backward-compat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const result = await this.recordRaceRatingEvents(raceId, raceResults);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update ratings via event system');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update individual driver rating based on race result
|
||||
* Update individual driver rating based on race result (LEGACY - DEPRECATED)
|
||||
* Kept for backward compatibility but now uses event-based approach
|
||||
*/
|
||||
private async updateDriverRating(result: {
|
||||
driverId: string;
|
||||
@@ -42,103 +123,104 @@ export class RatingUpdateService implements IDomainService {
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}): Promise<void> {
|
||||
const { driverId, position, totalDrivers, incidents, startPosition } = result;
|
||||
// Delegate to new event-based approach
|
||||
await this.updateDriverRatingsAfterRace([result]);
|
||||
}
|
||||
|
||||
// Get or create user rating
|
||||
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(driverId);
|
||||
}
|
||||
/**
|
||||
* Update trust score based on sportsmanship actions (USES LEDGER)
|
||||
*/
|
||||
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
|
||||
// Create trust-related rating event using manual event creation
|
||||
const now = new Date();
|
||||
const event = RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: driverId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(trustChange),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'manualAdjustment', id: `trust-${now.getTime()}` },
|
||||
reason: {
|
||||
code: trustChange > 0 ? 'TRUST_BONUS' : 'TRUST_PENALTY',
|
||||
summary: trustChange > 0 ? 'Positive sportsmanship' : 'Negative sportsmanship',
|
||||
details: { trustChange },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// Calculate performance score (0-100)
|
||||
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
|
||||
// Save event
|
||||
await this.ratingEventRepository.save(event);
|
||||
|
||||
// Calculate fairness score based on incidents (lower incidents = higher fairness)
|
||||
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
|
||||
// Recompute snapshot
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(driverId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
// Update ratings
|
||||
const updatedRating = userRating
|
||||
.updateDriverRating(performanceScore)
|
||||
.updateFairnessScore(fairnessScore);
|
||||
/**
|
||||
* Update steward rating based on protest handling quality (USES LEDGER)
|
||||
*/
|
||||
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
|
||||
// Create steward-related rating event using manual event creation
|
||||
const now = new Date();
|
||||
const event = RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: stewardId,
|
||||
dimension: RatingDimensionKey.create('adminTrust'),
|
||||
delta: RatingDelta.create(ratingChange),
|
||||
weight: 1,
|
||||
occurredAt: now,
|
||||
createdAt: now,
|
||||
source: { type: 'manualAdjustment', id: `steward-${now.getTime()}` },
|
||||
reason: {
|
||||
code: ratingChange > 0 ? 'STEWARD_BONUS' : 'STEWARD_PENALTY',
|
||||
summary: ratingChange > 0 ? 'Good protest handling' : 'Poor protest handling',
|
||||
details: { ratingChange },
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
});
|
||||
|
||||
// Save updated rating
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
// Save event
|
||||
await this.ratingEventRepository.save(event);
|
||||
|
||||
// Recompute snapshot
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(stewardId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(stewardId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance score based on finishing position and field strength
|
||||
* (Utility method kept for reference, but now handled by RatingEventFactory)
|
||||
*/
|
||||
private calculatePerformanceScore(
|
||||
position: number,
|
||||
totalDrivers: number,
|
||||
startPosition: number
|
||||
): number {
|
||||
// Base score from finishing position (reverse percentile)
|
||||
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||
|
||||
// Bonus for positions gained
|
||||
const positionsGained = startPosition - position;
|
||||
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
|
||||
|
||||
// Field strength adjustment (harder fields give higher scores for same position)
|
||||
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
|
||||
|
||||
const gainBonus = Math.max(0, positionsGained * 2);
|
||||
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50);
|
||||
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
|
||||
|
||||
// Clamp to 0-100 range
|
||||
return Math.max(0, Math.min(100, rawScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate fairness score based on incident involvement
|
||||
* (Utility method kept for reference, but now handled by RatingEventFactory)
|
||||
*/
|
||||
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
|
||||
// Base fairness score (100 = perfect, 0 = terrible)
|
||||
let fairnessScore = 100;
|
||||
|
||||
// Deduct points for incidents
|
||||
fairnessScore -= incidents * 15; // 15 points per incident
|
||||
|
||||
// Additional deduction for high incident rate relative to field
|
||||
fairnessScore -= incidents * 15;
|
||||
const incidentRate = incidents / totalDrivers;
|
||||
if (incidentRate > 0.5) {
|
||||
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
|
||||
fairnessScore -= 20;
|
||||
}
|
||||
|
||||
// Clamp to 0-100 range
|
||||
return Math.max(0, Math.min(100, fairnessScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update trust score based on sportsmanship actions
|
||||
*/
|
||||
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
|
||||
let userRating = await this.userRatingRepository.findByUserId(driverId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(driverId);
|
||||
}
|
||||
|
||||
// Convert trust change (-50 to +50) to 0-100 scale
|
||||
const currentTrust = userRating.trust.value;
|
||||
const newTrustValue = Math.max(0, Math.min(100, currentTrust + trustChange));
|
||||
|
||||
const updatedRating = userRating.updateTrustScore(newTrustValue);
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update steward rating based on protest handling quality
|
||||
*/
|
||||
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
|
||||
let userRating = await this.userRatingRepository.findByUserId(stewardId);
|
||||
if (!userRating) {
|
||||
userRating = UserRating.create(stewardId);
|
||||
}
|
||||
|
||||
const currentRating = userRating.steward.value;
|
||||
const newRatingValue = Math.max(0, Math.min(100, currentRating + ratingChange));
|
||||
|
||||
const updatedRating = userRating.updateStewardRating(newRatingValue);
|
||||
await this.userRatingRepository.save(updatedRating);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user