Files
gridpilot.gg/adapters/bootstrap/racing/RacingMembershipFactory.ts
2026-01-21 12:55:22 +01:00

351 lines
12 KiB
TypeScript

import { Driver } from '@core/racing/domain/entities/Driver';
import { JoinRequest } from '@core/racing/domain/entities/JoinRequest';
import { League } from '@core/racing/domain/entities/League';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { seedId } from './SeedIdHelper';
export class RacingMembershipFactory {
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
const memberships: LeagueMembership[] = [];
const leagueById = new Map(leagues.map(l => [l.id.toString(), l]));
const add = (props: {
leagueId: string;
driverId: string;
role: 'owner' | 'admin' | 'steward' | 'member';
status: 'active' | 'inactive' | 'pending';
joinedDaysAgo: number;
id?: string;
}): void => {
memberships.push(
LeagueMembership.create({
leagueId: props.leagueId,
driverId: props.driverId,
role: props.role,
status: props.status,
joinedAt: this.addDays(this.baseDate, -props.joinedDaysAgo),
...(props.id !== undefined ? { id: props.id } : {}),
}),
);
};
// Empty league: intentionally no memberships.
// (Keep `league-2` empty if it exists.)
// Widen the type to avoid TS2367 "no overlap" comparisons in some build modes.
const emptyLeagueId: string | undefined = leagueById.has(seedId('league-2', this.persistence)) ? (seedId('league-2', this.persistence) as string) : undefined;
// Demo league: "full" + overbooked with pending/inactive members.
const demoLeague = leagueById.get(seedId('league-5', this.persistence));
if (demoLeague) {
const maxDrivers = demoLeague.settings.maxDrivers ?? 32;
const activeDrivers = drivers.slice(0, Math.min(maxDrivers, drivers.length));
activeDrivers.forEach((driver, idx) => {
const driverId = driver.id.toString();
const expectedDriverId = seedId('driver-1', this.persistence);
const role =
driverId === expectedDriverId
? 'owner'
: idx === 1 || idx === 2
? 'admin'
: idx === 3 || idx === 4
? 'steward'
: 'member';
add({ leagueId: demoLeague.id.toString(), driverId, role, status: 'active', joinedDaysAgo: 60 - idx });
});
// Over-cap edge cases (membership exists but not active / pending)
const overbooked = drivers.slice(activeDrivers.length, activeDrivers.length + 4);
overbooked.forEach((driver, idx) => {
add({
leagueId: demoLeague.id.toString(),
driverId: driver.id.toString(),
role: 'member',
status: idx % 2 === 0 ? 'pending' : 'inactive',
joinedDaysAgo: 10 + idx,
});
});
}
// League with mixed statuses and roles (but not full).
const league1 = leagueById.get(seedId('league-1', this.persistence));
if (league1) {
const pick = drivers.slice(15, 25);
pick.forEach((driver, idx) => {
add({
leagueId: league1.id.toString(),
driverId: driver.id.toString(),
role: idx === 0 ? 'owner' : idx === 1 ? 'steward' : 'member',
status: idx % 5 === 0 ? 'pending' : idx % 7 === 0 ? 'inactive' : 'active',
joinedDaysAgo: 30 + idx,
});
});
}
// League with only pending memberships (tests "pending list" UX).
const league4 = leagueById.get(seedId('league-4', this.persistence));
if (league4) {
drivers.slice(40, 48).forEach((driver, idx) => {
add({
leagueId: league4.id.toString(),
driverId: driver.id.toString(),
role: idx === 0 ? 'owner' : 'member',
status: 'pending',
joinedDaysAgo: 3 + idx,
});
});
}
// Spread remaining drivers across remaining leagues to create realistic overlap.
// IMPORTANT: in postgres mode league ids are UUIDs, so never parse numeric suffixes from ids.
for (const driver of drivers) {
const driverId = driver.id.toString();
for (const league of leagues) {
const leagueId = league.id.toString();
if (leagueId === seedId('league-5', this.persistence)) continue;
if (emptyLeagueId && leagueId === emptyLeagueId) continue;
// Deterministic membership distribution that works with UUID ids.
// Use stable hash of (driverId + leagueId) rather than parsing numeric suffixes.
const distKey = `${driverId}:${leagueId}`;
const dist = this.stableHash(distKey);
// Some inactive memberships for league-3 to exercise edge cases.
if (leagueId === seedId('league-3', this.persistence) && dist % 11 === 0) {
add({
leagueId,
driverId,
role: 'member',
status: 'inactive',
joinedDaysAgo: 120,
});
continue;
}
// Sparse membership distribution (not every driver in every league)
if (dist % 9 === 0) {
add({
leagueId,
driverId,
role: 'member',
status: 'active',
joinedDaysAgo: 45,
});
}
}
}
return memberships;
}
createLeagueJoinRequests(
drivers: Driver[],
leagues: League[],
leagueMemberships: LeagueMembership[],
): JoinRequest[] {
const membershipIds = new Set<string>(leagueMemberships.map(m => m.id.toString()));
const requests: JoinRequest[] = [];
const addRequest = (input: { leagueId: string; driverId: string; id?: string; message?: string; requestedAt?: Date }) => {
requests.push(
JoinRequest.create({
leagueId: input.leagueId,
driverId: input.driverId,
...(input.id !== undefined && { id: input.id }),
...(input.message !== undefined && { message: input.message }),
...(input.requestedAt !== undefined && { requestedAt: input.requestedAt }),
}),
);
};
// League with lots of requests + membership/request conflicts (everyone is a member of league-5 already).
const demoLeagueId = seedId('league-5', this.persistence);
const demoDrivers = drivers.slice(10, 35);
demoDrivers.forEach((driver, idx) => {
const message =
idx % 4 === 0
? 'Interested in consistent stewarding and clean racing.'
: idx % 4 === 1
? undefined
: idx % 4 === 2
? ''
: 'Can I join mid-season and still be eligible for points?';
addRequest({
leagueId: demoLeagueId,
driverId: driver.id.toString(),
requestedAt: this.addDays(this.baseDate, -(7 + idx)),
...(message !== undefined && { message }),
});
});
// League with a few "normal" requests (only drivers who are NOT members already).
const targetLeagueId = seedId('league-1', this.persistence);
const nonMembers = drivers
.filter(driver => !membershipIds.has(`${targetLeagueId}:${driver.id.toString()}`))
.slice(0, 6);
nonMembers.forEach((driver, idx) => {
addRequest({
leagueId: targetLeagueId,
driverId: driver.id.toString(),
requestedAt: this.addDays(this.baseDate, -(3 + idx)),
...(idx % 2 === 0 && { message: 'Looking for regular endurance rounds and stable race times.' }),
});
});
// Single request with no message (explicit id).
const league3Exists = leagues.some(l => l.id.toString() === seedId('league-3', this.persistence));
if (league3Exists && drivers[0]) {
addRequest({
id: seedId('league-3-join-req-1', this.persistence),
leagueId: seedId('league-3', this.persistence),
driverId: drivers[0].id.toString(),
requestedAt: this.addDays(this.baseDate, -9),
});
}
// Duplicate id edge case (last write wins in in-memory repo).
if (drivers[1]) {
addRequest({
id: seedId('dup-league-join-req-1', this.persistence),
leagueId: seedId('league-7', this.persistence),
driverId: drivers[1].id.toString(),
requestedAt: this.addDays(this.baseDate, -2),
message: 'First request message (will be overwritten).',
});
addRequest({
id: seedId('dup-league-join-req-1', this.persistence),
leagueId: seedId('league-7', this.persistence),
driverId: drivers[1].id.toString(),
requestedAt: this.addDays(this.baseDate, -1),
message: 'Updated request message (duplicate id).',
});
}
// Explicit conflict: join request exists even though membership exists.
const expectedDriverId = seedId('driver-1', this.persistence);
const driver1 = drivers.find(d => d.id.toString() === expectedDriverId);
if (driver1) {
addRequest({
id: seedId('conflict-req-league-5-driver-1', this.persistence),
leagueId: demoLeagueId,
driverId: driver1.id.toString(),
requestedAt: this.addDays(this.baseDate, -15),
message: 'Testing UI edge case: request exists for an existing member.',
});
}
return requests;
}
createRaceRegistrations(
races: Race[],
drivers: Driver[],
leagueMemberships: LeagueMembership[],
): RaceRegistration[] {
const registrations: RaceRegistration[] = [];
const activeMembershipKey = new Set(
leagueMemberships
.filter(m => m.status.toString() === 'active')
.map(m => `${m.leagueId.toString()}:${m.driverId.toString()}`),
);
const scheduled = races.filter((r) => r.status.toString() === 'scheduled');
for (const race of scheduled) {
const leagueId = race.leagueId.toString();
const targetCount = (race as unknown as { registeredCount?: number }).registeredCount ?? 0;
// 25%: intentionally no registrations
if (Number(race.id.toString().split('-')[1] ?? 0) % 4 === 0) {
continue;
}
const eligibleDrivers = drivers
.map(d => d.id.toString())
.filter(driverId => activeMembershipKey.has(`${leagueId}:${driverId}`));
const desired = Math.min(
eligibleDrivers.length,
Math.max(1, targetCount > 0 ? targetCount : 3),
);
const start = Number(race.id.toString().split('-')[1] ?? 0);
for (let i = 0; i < desired; i++) {
const driverId = eligibleDrivers[(start + i) % eligibleDrivers.length];
if (!driverId) continue;
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId,
}),
);
}
// Edge case: one "outsider" registration (driver not active in league)
if (eligibleDrivers.length > 0 && drivers.length > eligibleDrivers.length) {
const outsider = drivers
.map(d => d.id.toString())
.find(driverId => !activeMembershipKey.has(`${leagueId}:${driverId}`));
if (outsider && start % 7 === 0) {
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId: outsider,
}),
);
}
}
// Edge case: duplicate registration (should be ignored by repo if unique constrained)
if (start % 9 === 0 && registrations.length > 0) {
const last = registrations[registrations.length - 1]!;
registrations.push(
RaceRegistration.create({
raceId: last.raceId.toString(),
driverId: last.driverId.toString(),
}),
);
}
}
// Keep a tiny curated "happy path" for the demo league as well
const upcomingDemoLeague = races.filter((r) => r.status.toString() === 'scheduled' && r.leagueId === seedId('league-5', this.persistence)).slice(0, 3);
for (const race of upcomingDemoLeague) {
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId: seedId('driver-1', this.persistence),
}),
);
}
return registrations;
}
private stableHash(input: string): number {
// Simple deterministic string hash (non-crypto), stable across runs.
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = (hash * 31 + input.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
private addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
}