This commit is contained in:
2025-12-29 22:27:33 +01:00
parent 3f610c1cb6
commit 7a853d4e43
96 changed files with 14790 additions and 111 deletions

View 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);
});
});
});

View 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);
}
}

View 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);
});
});
});

View 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';
}
}

View 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);
});
});
});

View 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})`;
}
}

View 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);
}
});
});
});

View 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';

View File

@@ -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);
});
});
});

View 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;
}
}

View 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);
});
});
});

View File

@@ -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);
}
}