From c8f708562d33cbc8d3441492a536b187834b4f69 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 11:38:20 -0400 Subject: [PATCH 01/42] Add prepare script for Effect devtools --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 9fc51d6b1..ac8f342c1 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev --turbopack", "build": "next build", + "prepare": "effect-language-service patch", "postinstall": "prisma generate", "start": "next start", "lint": "next typegen && oxlint", From 01006b9ec3c5062b936ad8f9fd64ac08ce78cc39 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 11:38:36 -0400 Subject: [PATCH 02/42] Replace Effect.fail with direct error yield --- src/lib/r2/index.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/lib/r2/index.ts b/src/lib/r2/index.ts index 72ffb81ee..d9fc6d5d5 100644 --- a/src/lib/r2/index.ts +++ b/src/lib/r2/index.ts @@ -17,7 +17,6 @@ import { Schedule, } from "effect"; import { - ConfigurationError, DeleteError, DownloadError, PresignError, @@ -226,12 +225,10 @@ export const make: Effect.Effect = ); if (!response.Body) { - return yield* Effect.fail( - new DownloadError({ - key, - cause: new Error("Empty response body"), - }) - ); + return yield* new DownloadError({ + key, + cause: new Error("Empty response body"), + }); } const bytes = yield* Effect.tryPromise({ From de99fae32a806768b734629301ac8c960f742b88 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 13:34:02 -0400 Subject: [PATCH 03/42] Refactor for consistency with new services --- src/lib/audit-logs/index.ts | 2 +- src/lib/notifications/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/audit-logs/index.ts b/src/lib/audit-logs/index.ts index 3a030b843..1b9bb7b04 100644 --- a/src/lib/audit-logs/index.ts +++ b/src/lib/audit-logs/index.ts @@ -9,7 +9,7 @@ export type Service = { createAuditLog(args: AuditLogArgs): Effect.Effect; }; -export class AuditLogService extends Context.Tag("AuditLogService")< +export class AuditLogService extends Context.Tag("@app/audit-logs/AuditLogService")< AuditLogService, Service >() {} diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 12472b125..b4afc3631 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -43,7 +43,7 @@ export type Service = { ): Effect.Effect; }; -export class NotificationService extends Context.Tag("NotificationService")< +export class NotificationService extends Context.Tag("@app/notifications/NotificationService")< NotificationService, Service >() {} From e2f3956169fcf8b5c186afbf4a6dc6892bef8500 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:54:43 -0400 Subject: [PATCH 04/42] Add zed settings --- .zed/settings.json | 134 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 .zed/settings.json diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..c6fd40017 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,134 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "lsp": { + "oxfmt": { + "initialization_options": { + "settings": { + "fmt.configPath": null, + }, + }, + }, + }, + "languages": { + "CSS": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "HTML": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "JavaScript": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "JSON": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "JSONC": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "Markdown": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "TypeScript": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "TSX": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + "YAML": { + "format_on_save": "on", + "prettier": { + "allowed": false, + }, + "formatter": [ + { + "language_server": { + "name": "oxfmt", + }, + }, + ], + }, + }, +} From 5d9bc4f485cdd6ad5067c4fc4c55ca561559acf5 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:54:54 -0400 Subject: [PATCH 05/42] Minor styling changes --- src/lib/audit-logs/index.ts | 7 +++---- src/lib/notifications/index.ts | 15 +++++++-------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lib/audit-logs/index.ts b/src/lib/audit-logs/index.ts index 1b9bb7b04..c9ddd80e7 100644 --- a/src/lib/audit-logs/index.ts +++ b/src/lib/audit-logs/index.ts @@ -9,10 +9,9 @@ export type Service = { createAuditLog(args: AuditLogArgs): Effect.Effect; }; -export class AuditLogService extends Context.Tag("@app/audit-logs/AuditLogService")< - AuditLogService, - Service ->() {} +export class AuditLogService extends Context.Tag( + "@app/audit-logs/AuditLogService" +)() {} // Service implementation function createService(): Effect.Effect { diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index b4afc3631..d129fa5b5 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -43,10 +43,9 @@ export type Service = { ): Effect.Effect; }; -export class NotificationService extends Context.Tag("@app/notifications/NotificationService")< - NotificationService, - Service ->() {} +export class NotificationService extends Context.Tag( + "@app/notifications/NotificationService" +)() {} // Service implementation function createService(config: { prisma: typeof prisma }) { @@ -250,8 +249,8 @@ function createService(config: { prisma: typeof prisma }) { ), markAllAsRead: (userId: string) => - Effect.gen(function* () { - yield* Effect.tryPromise({ + Effect.asVoid( + Effect.tryPromise({ try: () => prisma.notification.updateMany({ where: { userId, read: false }, @@ -266,8 +265,8 @@ function createService(config: { prisma: typeof prisma }) { Effect.withSpan("notification.update-all-read", { attributes: { userId }, }) - ); - }).pipe( + ) + ).pipe( Effect.withSpan("notification.markAllAsRead", { attributes: { userId }, }) From 4defe5678abec3c5313b7c8ac49e0793713d2634 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:58:44 -0400 Subject: [PATCH 06/42] Add team shared data Effect service foundation Rewrite team-shared-core and team-shared-data as Effect services with Context.Tag, Layer composition, Cache.make with 30s TTL, wide event logging, OTel spans, and typed errors via S.TaggedError. These form the base layer that all other team services depend on. --- src/data/team/errors.ts | 24 + src/data/team/metrics.ts | 77 ++++ src/data/team/shared-core.ts | 192 ++++++++ src/data/team/shared-data-service.ts | 627 +++++++++++++++++++++++++++ src/data/team/types.ts | 22 + 5 files changed, 942 insertions(+) create mode 100644 src/data/team/errors.ts create mode 100644 src/data/team/metrics.ts create mode 100644 src/data/team/shared-core.ts create mode 100644 src/data/team/shared-data-service.ts create mode 100644 src/data/team/types.ts diff --git a/src/data/team/errors.ts b/src/data/team/errors.ts new file mode 100644 index 000000000..f3d2706cb --- /dev/null +++ b/src/data/team/errors.ts @@ -0,0 +1,24 @@ +import { Schema as S } from "effect"; + +export class TeamNotFoundError extends S.TaggedError()( + "TeamNotFoundError", + { + teamId: S.Number, + } +) { + get message(): string { + return `Team not found: ${this.teamId}`; + } +} + +export class TeamQueryError extends S.TaggedError()( + "TeamQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Team query failed: ${this.operation}`; + } +} diff --git a/src/data/team/metrics.ts b/src/data/team/metrics.ts new file mode 100644 index 000000000..1e173873f --- /dev/null +++ b/src/data/team/metrics.ts @@ -0,0 +1,77 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const teamRosterQuerySuccessTotal = Metric.counter( + "team.roster.query.success", + { + description: "Total successful team roster queries", + incremental: true, + } +); + +export const teamRosterQueryErrorTotal = Metric.counter( + "team.roster.query.error", + { + description: "Total team roster query failures", + incremental: true, + } +); + +export const teamRosterQueryDuration = Metric.histogram( + "team.roster.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team roster query duration in milliseconds" +); + +export const teamBaseDataQuerySuccessTotal = Metric.counter( + "team.base_data.query.success", + { + description: "Total successful base team data queries", + incremental: true, + } +); + +export const teamBaseDataQueryErrorTotal = Metric.counter( + "team.base_data.query.error", + { + description: "Total base team data query failures", + incremental: true, + } +); + +export const teamBaseDataQueryDuration = Metric.histogram( + "team.base_data.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of base team data query duration in milliseconds" +); + +export const teamExtendedDataQuerySuccessTotal = Metric.counter( + "team.extended_data.query.success", + { + description: "Total successful extended team data queries", + incremental: true, + } +); + +export const teamExtendedDataQueryErrorTotal = Metric.counter( + "team.extended_data.query.error", + { + description: "Total extended team data query failures", + incremental: true, + } +); + +export const teamExtendedDataQueryDuration = Metric.histogram( + "team.extended_data.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of extended team data query duration in milliseconds" +); + +export const teamCacheRequestTotal = Metric.counter("team.cache.request", { + description: "Total team data cache requests", + incremental: true, +}); + +export const teamCacheMissTotal = Metric.counter("team.cache.miss", { + description: "Total team data cache misses (triggered lookup)", + incremental: true, +}); diff --git a/src/data/team/shared-core.ts b/src/data/team/shared-core.ts new file mode 100644 index 000000000..5ed5500ea --- /dev/null +++ b/src/data/team/shared-core.ts @@ -0,0 +1,192 @@ +import type { + Kill, + MatchStart, + MercyRez, + ObjectiveCaptured, + PayloadProgress, + PointProgress, + RoundEnd, + UltimateStart, +} from "@prisma/client"; + +export type BaseTeamData = { + teamId: number; + teamRoster: string[]; + teamRosterSet: Set; + mapDataRecords: { + id: number; + name: string | null; + Scrim?: { + id: number; + name: string; + date: Date; + }; + }[]; + mapDataIds: number[]; + allPlayerStats: { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + eliminations: number; + final_blows: number; + deaths: number; + offensive_assists: number; + hero_damage_dealt: number; + damage_taken: number; + healing_dealt: number; + ultimates_earned: number; + ultimates_used: number; + }[]; + matchStarts: MatchStart[]; + finalRounds: RoundEnd[]; + captures: ObjectiveCaptured[]; + payloadProgresses: PayloadProgress[]; + pointProgresses: PointProgress[]; +}; + +export type ExtendedTeamData = BaseTeamData & { + allKills: Kill[]; + allRezzes: MercyRez[]; + allUltimates: UltimateStart[]; +}; + +export type TeamDateRange = { + from: Date; + to: Date; +}; + +export function findTeamNameForMapInMemory( + mapDataId: number, + allPlayerStats: { + player_name: string; + player_team: string; + MapDataId: number | null; + }[], + teamRosterSet: Set +): string | null { + const teamCounts = new Map(); + + for (const stat of allPlayerStats) { + if (stat.MapDataId === mapDataId && teamRosterSet.has(stat.player_name)) { + const currentCount = teamCounts.get(stat.player_team) ?? 0; + teamCounts.set(stat.player_team, currentCount + 1); + } + } + + let maxCount = 0; + let teamName: string | null = null; + + for (const [team, count] of teamCounts.entries()) { + if (count > maxCount) { + maxCount = count; + teamName = team; + } + } + + return teamName; +} + +export function buildFinalRoundMap( + finalRounds: RoundEnd[] +): Map { + const finalRoundMap = new Map(); + + for (const round of finalRounds) { + const mapDataId = round.MapDataId; + if (mapDataId) { + const existing = finalRoundMap.get(mapDataId); + if (!existing || round.round_number > existing.round_number) { + finalRoundMap.set(mapDataId, round); + } + } + } + + return finalRoundMap; +} + +export function buildMatchStartMap( + matchStarts: MatchStart[] +): Map { + const matchStartMap = new Map(); + + for (const match of matchStarts) { + if (match.MapDataId) { + matchStartMap.set(match.MapDataId, match); + } + } + + return matchStartMap; +} + +export function buildCapturesMaps( + captures: ObjectiveCaptured[], + matchStartMap: Map +): { + team1CapturesMap: Map; + team2CapturesMap: Map; +} { + const team1CapturesMap = new Map(); + const team2CapturesMap = new Map(); + + for (const capture of captures) { + const mapDataId = capture.MapDataId; + if (!mapDataId) continue; + + const match = matchStartMap.get(mapDataId); + if (!match) continue; + + if (capture.capturing_team === match.team_1_name) { + if (!team1CapturesMap.has(mapDataId)) { + team1CapturesMap.set(mapDataId, []); + } + team1CapturesMap.get(mapDataId)!.push(capture); + } else if (capture.capturing_team === match.team_2_name) { + if (!team2CapturesMap.has(mapDataId)) { + team2CapturesMap.set(mapDataId, []); + } + team2CapturesMap.get(mapDataId)!.push(capture); + } + } + + return { team1CapturesMap, team2CapturesMap }; +} + +export function buildProgressMaps< + T extends { + MapDataId: number | null; + capturing_team: string; + }, +>( + progressRows: T[], + matchStartMap: Map +): { + team1ProgressMap: Map; + team2ProgressMap: Map; +} { + const team1ProgressMap = new Map(); + const team2ProgressMap = new Map(); + + for (const progressRow of progressRows) { + const mapDataId = progressRow.MapDataId; + if (!mapDataId) continue; + + const match = matchStartMap.get(mapDataId); + if (!match) continue; + + if (progressRow.capturing_team === match.team_1_name) { + if (!team1ProgressMap.has(mapDataId)) { + team1ProgressMap.set(mapDataId, []); + } + team1ProgressMap.get(mapDataId)!.push(progressRow); + } else if (progressRow.capturing_team === match.team_2_name) { + if (!team2ProgressMap.has(mapDataId)) { + team2ProgressMap.set(mapDataId, []); + } + team2ProgressMap.get(mapDataId)!.push(progressRow); + } + } + + return { team1ProgressMap, team2ProgressMap }; +} diff --git a/src/data/team/shared-data-service.ts b/src/data/team/shared-data-service.ts new file mode 100644 index 000000000..0747b1ae7 --- /dev/null +++ b/src/data/team/shared-data-service.ts @@ -0,0 +1,627 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { TeamQueryError } from "./errors"; +import { + teamBaseDataQueryDuration, + teamBaseDataQueryErrorTotal, + teamBaseDataQuerySuccessTotal, + teamCacheRequestTotal, + teamCacheMissTotal, + teamExtendedDataQueryDuration, + teamExtendedDataQueryErrorTotal, + teamExtendedDataQuerySuccessTotal, + teamRosterQueryDuration, + teamRosterQueryErrorTotal, + teamRosterQuerySuccessTotal, +} from "./metrics"; +import type { + BaseTeamData, + ExtendedTeamData, + TeamDateRange, +} from "./shared-core"; + +export type BaseTeamDataOptions = { + excludePush?: boolean; + excludeClash?: boolean; + includeDateInfo?: boolean; + dateRange?: TeamDateRange; +}; + +export type TeamSharedDataServiceInterface = { + readonly getTeamRoster: ( + teamId: number + ) => Effect.Effect; + + readonly getBaseTeamData: ( + teamId: number, + options?: BaseTeamDataOptions + ) => Effect.Effect; + + readonly getExtendedTeamData: ( + teamId: number, + options?: BaseTeamDataOptions + ) => Effect.Effect; +}; + +export class TeamSharedDataService extends Context.Tag( + "@app/data/team/TeamSharedDataService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getTeamRoster( + teamId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const mapRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: { Team: { id: teamId } } }, + select: { mapData: { select: { id: true } } }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch map records for roster", + cause: error, + }), + }).pipe( + Effect.withSpan("team.roster.fetchMapRecords", { + attributes: { teamId }, + }) + ); + + const mapDataIds = mapRecords.flatMap((m) => + m.mapData.map((md) => md.id) + ); + + if (mapDataIds.length === 0) { + wideEvent.roster_size = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(teamRosterQuerySuccessTotal); + const _empty: string[] = []; return _empty; + } + + const allPlayerStats = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + MapDataId: true, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch player stats for roster", + cause: error, + }), + }).pipe( + Effect.withSpan("team.roster.fetchPlayerStats", { + attributes: { teamId, mapCount: mapDataIds.length }, + }) + ); + + const playerFrequencyMap = new Map(); + const mapPlayerSets = new Map>(); + + for (const stat of allPlayerStats) { + const mapDataId = stat.MapDataId; + if (!mapDataId) continue; + + if (!mapPlayerSets.has(mapDataId)) { + mapPlayerSets.set(mapDataId, new Set()); + } + + const playersInMap = mapPlayerSets.get(mapDataId)!; + if (!playersInMap.has(stat.player_name)) { + playersInMap.add(stat.player_name); + const currentCount = playerFrequencyMap.get(stat.player_name) ?? 0; + playerFrequencyMap.set(stat.player_name, currentCount + 1); + } + } + + const sortedPlayers = Array.from(playerFrequencyMap.entries()).sort( + (a, b) => b[1] - a[1] + ); + + if (sortedPlayers.length === 0) { + wideEvent.roster_size = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(teamRosterQuerySuccessTotal); + const _empty: string[] = []; return _empty; + } + + let anchorPlayer: string | null = null; + for (const [playerName] of sortedPlayers) { + const playerExists = allPlayerStats.some( + (stat) => stat.player_name === playerName + ); + if (playerExists) { + anchorPlayer = playerName; + break; + } + } + + if (!anchorPlayer) { + wideEvent.roster_size = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(teamRosterQuerySuccessTotal); + const _empty: string[] = []; return _empty; + } + + function findTeamNameForMap(mapDataId: number): string | null { + for (const [playerName] of sortedPlayers) { + for (const stat of allPlayerStats) { + if ( + stat.MapDataId === mapDataId && + stat.player_name === playerName + ) { + return stat.player_team; + } + } + } + return null; + } + + const rosterPlayers = new Set(); + + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMap(mapDataId); + + if (teamName) { + for (const stat of allPlayerStats) { + if ( + stat.MapDataId === mapDataId && + stat.player_team === teamName + ) { + rosterPlayers.add(stat.player_name); + } + } + } + } + + const roster = Array.from(rosterPlayers); + wideEvent.roster_size = roster.length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamRosterQuerySuccessTotal); + return roster; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(teamRosterQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.getTeamRoster") + : Effect.logInfo("team.getTeamRoster"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + teamRosterQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("team.getTeamRoster") + ); + } + + function getBaseTeamData( + teamId: number, + options: BaseTeamDataOptions = {} + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + excludePush: options.excludePush, + excludeClash: options.excludeClash, + includeDateInfo: options.includeDateInfo, + hasDateRange: !!options.dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const { + excludePush = false, + excludeClash = false, + includeDateInfo = false, + dateRange, + } = options; + + // Call the local closure directly — no context dependency + const teamRoster = yield* getTeamRoster(teamId); + const teamRosterSet = new Set(teamRoster); + + const scrimWhereClause: Record = { + Team: { id: teamId }, + }; + if (dateRange) { + scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; + } + + const allMapRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: scrimWhereClause }, + select: { + id: true, + name: true, + mapData: { select: { id: true } }, + ...(includeDateInfo && { + Scrim: { + select: { + id: true, + name: true, + date: true, + }, + }, + }), + }, + ...(includeDateInfo && { + orderBy: { + Scrim: { + date: "desc" as const, + }, + }, + }), + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch map records for base data", + cause: error, + }), + }).pipe( + Effect.withSpan("team.baseData.fetchMapRecords", { + attributes: { teamId }, + }) + ); + + let filteredMapRecords = allMapRecords; + if (excludePush || excludeClash) { + filteredMapRecords = allMapRecords.filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + if (excludePush && mapType === $Enums.MapType.Push) return false; + if (excludeClash && mapType === $Enums.MapType.Clash) return false; + return true; + }); + } + + const mapDataIds = filteredMapRecords.flatMap((m) => + m.mapData.map((md) => md.id) + ); + + const mapDataRecords = filteredMapRecords.flatMap((m) => + m.mapData.map((md) => ({ + id: md.id, + name: m.name, + ...("Scrim" in m && m.Scrim ? { Scrim: m.Scrim } : {}), + })) + ); + + if (mapDataIds.length === 0) { + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(teamBaseDataQuerySuccessTotal); + return { + teamId, + teamRoster, + teamRosterSet, + mapDataRecords: [], + mapDataIds: [], + allPlayerStats: [], + matchStarts: [], + finalRounds: [], + captures: [], + payloadProgresses: [], + pointProgresses: [], + } satisfies BaseTeamData; + } + + const [ + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + MapDataId: true, + eliminations: true, + final_blows: true, + deaths: true, + offensive_assists: true, + hero_damage_dealt: true, + damage_taken: true, + healing_dealt: true, + ultimates_earned: true, + ultimates_used: true, + }, + }), + prisma.matchStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { round_number: "desc" }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch base team data tables", + cause: error, + }), + }).pipe( + Effect.withSpan("team.baseData.fetchAllTables", { + attributes: { teamId, mapCount: mapDataIds.length }, + }) + ); + + wideEvent.map_count = mapDataIds.length; + wideEvent.player_stat_count = allPlayerStats.length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamBaseDataQuerySuccessTotal); + + return { + teamId, + teamRoster, + teamRosterSet, + mapDataRecords: mapDataRecords as BaseTeamData["mapDataRecords"], + mapDataIds, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } satisfies BaseTeamData; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(teamBaseDataQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.getBaseTeamData") + : Effect.logInfo("team.getBaseTeamData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + teamBaseDataQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("team.getBaseTeamData") + ); + } + + function getExtendedTeamData( + teamId: number, + options: BaseTeamDataOptions = {} + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + // Call the local closure directly — no context dependency + const baseData = yield* getBaseTeamData(teamId, options); + + if (baseData.mapDataIds.length === 0) { + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(teamExtendedDataQuerySuccessTotal); + return { + ...baseData, + allKills: [], + allRezzes: [], + allUltimates: [], + } satisfies ExtendedTeamData; + } + + const [allKills, allRezzes, allUltimates] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { MapDataId: { in: baseData.mapDataIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.mercyRez.findMany({ + where: { MapDataId: { in: baseData.mapDataIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.ultimateStart.findMany({ + where: { MapDataId: { in: baseData.mapDataIds } }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch extended team data (kills, rezzes, ults)", + cause: error, + }), + }).pipe( + Effect.withSpan("team.extendedData.fetchFightTables", { + attributes: { + teamId, + mapCount: baseData.mapDataIds.length, + }, + }) + ); + + wideEvent.map_count = baseData.mapDataIds.length; + wideEvent.kill_count = allKills.length; + wideEvent.rez_count = allRezzes.length; + wideEvent.ult_count = allUltimates.length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamExtendedDataQuerySuccessTotal); + + return { + ...baseData, + allKills, + allRezzes, + allUltimates, + } satisfies ExtendedTeamData; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(teamExtendedDataQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.getExtendedTeamData") + : Effect.logInfo("team.getExtendedTeamData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + teamExtendedDataQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("team.getExtendedTeamData") + ); + } + + const rosterCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamId: number) => + getTeamRoster(teamId).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ), + }); + + function baseDataCacheKeyOf( + teamId: number, + options?: BaseTeamDataOptions + ) { + return `${teamId}:${JSON.stringify(options ?? {})}`; + } + + const baseDataCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, optionsJson] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const teamId = Number(teamIdStr); + const options = JSON.parse(optionsJson) as BaseTeamDataOptions; + return getBaseTeamData(teamId, options).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const extendedDataCacheKeyOf = baseDataCacheKeyOf; + + const extendedDataCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, optionsJson] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const teamId = Number(teamIdStr); + const options = JSON.parse(optionsJson) as BaseTeamDataOptions; + return getExtendedTeamData(teamId, options).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamRoster: (teamId: number) => + rosterCache + .get(teamId) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getBaseTeamData: (teamId: number, options?: BaseTeamDataOptions) => + baseDataCache + .get(baseDataCacheKeyOf(teamId, options)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getExtendedTeamData: (teamId: number, options?: BaseTeamDataOptions) => + extendedDataCache + .get(extendedDataCacheKeyOf(teamId, options)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamSharedDataServiceInterface; + } +); + +export const TeamSharedDataServiceLive = Layer.effect( + TeamSharedDataService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/team/types.ts b/src/data/team/types.ts new file mode 100644 index 000000000..e3c76bb72 --- /dev/null +++ b/src/data/team/types.ts @@ -0,0 +1,22 @@ +import { Schema as S } from "effect"; + +export const TeamIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Team ID must be a positive integer" }) +); + +export const BaseTeamDataOptionsSchema = S.Struct({ + excludePush: S.optional(S.Boolean), + excludeClash: S.optional(S.Boolean), + includeDateInfo: S.optional(S.Boolean), + dateRange: S.optional( + S.Struct({ + from: S.Date, + to: S.Date, + }) + ), +}); + +export type BaseTeamDataOptions = S.Schema.Type< + typeof BaseTeamDataOptionsSchema +>; From 4d0560f05f8d9e38d053d87ea5cc802290bac8d7 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:58:51 -0400 Subject: [PATCH 07/42] Add user, admin, hero, and tournament Effect services Leaf services with no cross-domain dependencies. Each follows the standard 4-file pattern (service, errors, types, metrics) with Effect caching, wide event logging, and OTel spans. --- src/data/admin/data-labeling-service.ts | 368 +++++++++++++++ src/data/admin/errors.ts | 24 + src/data/admin/index.ts | 16 + src/data/admin/metrics.ts | 55 +++ src/data/admin/types.ts | 70 +++ src/data/hero/errors.ts | 13 + src/data/hero/index.ts | 6 + src/data/hero/metrics.ts | 77 ++++ src/data/hero/service.ts | 395 ++++++++++++++++ src/data/hero/types.ts | 12 + src/data/tournament/broadcast-service.ts | 388 ++++++++++++++++ src/data/tournament/errors.ts | 47 ++ src/data/tournament/index.ts | 14 + src/data/tournament/metrics.ts | 162 +++++++ src/data/tournament/tournament-service.ts | 530 ++++++++++++++++++++++ src/data/tournament/types.ts | 54 +++ src/data/user/errors.ts | 24 + src/data/user/index.ts | 6 + src/data/user/metrics.ts | 71 +++ src/data/user/service.ts | 289 ++++++++++++ src/data/user/types.ts | 5 + 21 files changed, 2626 insertions(+) create mode 100644 src/data/admin/data-labeling-service.ts create mode 100644 src/data/admin/errors.ts create mode 100644 src/data/admin/index.ts create mode 100644 src/data/admin/metrics.ts create mode 100644 src/data/admin/types.ts create mode 100644 src/data/hero/errors.ts create mode 100644 src/data/hero/index.ts create mode 100644 src/data/hero/metrics.ts create mode 100644 src/data/hero/service.ts create mode 100644 src/data/hero/types.ts create mode 100644 src/data/tournament/broadcast-service.ts create mode 100644 src/data/tournament/errors.ts create mode 100644 src/data/tournament/index.ts create mode 100644 src/data/tournament/metrics.ts create mode 100644 src/data/tournament/tournament-service.ts create mode 100644 src/data/tournament/types.ts create mode 100644 src/data/user/errors.ts create mode 100644 src/data/user/index.ts create mode 100644 src/data/user/metrics.ts create mode 100644 src/data/user/service.ts create mode 100644 src/data/user/types.ts diff --git a/src/data/admin/data-labeling-service.ts b/src/data/admin/data-labeling-service.ts new file mode 100644 index 000000000..7cca9c122 --- /dev/null +++ b/src/data/admin/data-labeling-service.ts @@ -0,0 +1,368 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { DataLabelingQueryError, MatchNotFoundError } from "./errors"; +import { + matchForLabelingQueryDuration, + matchForLabelingQueryErrorTotal, + matchForLabelingQuerySuccessTotal, + unlabeledMatchesQueryDuration, + unlabeledMatchesQueryErrorTotal, + unlabeledMatchesQuerySuccessTotal, + adminCacheRequestTotal, + adminCacheMissTotal, +} from "./metrics"; +import type { + MatchForLabeling, + RosterPlayerForLabeling, + UnlabeledMatchesResult, +} from "./types"; + +export type DataLabelingServiceInterface = { + readonly getUnlabeledMatches: ( + page: number, + pageSize: number + ) => Effect.Effect; + + readonly getMatchForLabeling: ( + matchId: number + ) => Effect.Effect< + MatchForLabeling, + DataLabelingQueryError | MatchNotFoundError + >; +}; + +export class DataLabelingService extends Context.Tag( + "@app/data/admin/DataLabelingService" +)() {} + +export const make: Effect.Effect = Effect.gen( + function* () { + function getRosterPlayers( + tournamentId: number, + teamFullName: string + ): Effect.Effect { + return Effect.tryPromise({ + try: () => + prisma.scoutingRoster + .findUnique({ + where: { + tournamentId_teamName: { + tournamentId, + teamName: teamFullName, + }, + }, + include: { + players: { + where: { category: { in: ["player", "substitute"] } }, + select: { id: true, displayName: true, role: true }, + orderBy: { id: "asc" }, + }, + }, + }) + .then((roster) => roster?.players ?? []), + catch: (error) => + new DataLabelingQueryError({ + operation: `fetch roster players for tournament=${tournamentId} team=${teamFullName}`, + cause: error, + }), + }).pipe( + Effect.withSpan("admin.getRosterPlayers", { + attributes: { tournamentId, teamFullName }, + }) + ); + } + + function getUnlabeledMatches( + page: number, + pageSize: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { page, pageSize }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("page", page); + yield* Effect.annotateCurrentSpan("pageSize", pageSize); + const where = { + NOT: { vods: { equals: "[]" as unknown as undefined } }, + maps: { + some: { + OR: [ + { team1Comp: { isEmpty: true } }, + { team2Comp: { isEmpty: true } }, + ], + }, + }, + }; + + const [matches, totalCount] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.scoutingMatch.findMany({ + where, + include: { + maps: { + select: { id: true, team1Comp: true, team2Comp: true }, + }, + tournament: { select: { title: true } }, + }, + orderBy: { matchDate: "desc" }, + skip: page * pageSize, + take: pageSize, + }), + prisma.scoutingMatch.count({ where }), + ]), + catch: (error) => + new DataLabelingQueryError({ + operation: "fetch unlabeled matches", + cause: error, + }), + }).pipe( + Effect.withSpan("admin.unlabeledMatches.fetchData", { + attributes: { page, pageSize }, + }) + ); + + wideEvent.totalCount = totalCount; + wideEvent.matchCount = matches.length; + + const result: UnlabeledMatchesResult = { + matches: matches.map((m) => { + const vods = m.vods as { url: string; platform: string }[]; + const labeledMaps = m.maps.filter( + (map) => map.team1Comp.length > 0 && map.team2Comp.length > 0 + ).length; + + return { + id: m.id, + team1: m.team1, + team1FullName: m.team1FullName, + team2: m.team2, + team2FullName: m.team2FullName, + team1Score: m.team1Score, + team2Score: m.team2Score, + matchDate: m.matchDate, + tournament: m.tournament.title, + vodCount: vods.length, + labeledMaps, + totalMaps: m.maps.length, + }; + }), + totalCount, + page, + pageSize, + }; + + wideEvent.outcome = "success"; + yield* Metric.increment(unlabeledMatchesQuerySuccessTotal); + + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(unlabeledMatchesQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("admin.getUnlabeledMatches") + : Effect.logInfo("admin.getUnlabeledMatches"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + unlabeledMatchesQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("admin.getUnlabeledMatches") + ); + } + + function getMatchForLabeling( + matchId: number + ): Effect.Effect< + MatchForLabeling, + DataLabelingQueryError | MatchNotFoundError + > { + const startTime = Date.now(); + const wideEvent: Record = { matchId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("matchId", matchId); + const match = yield* Effect.tryPromise({ + try: () => + prisma.scoutingMatch.findUnique({ + where: { id: matchId }, + include: { + maps: { + include: { + heroBans: { + select: { + id: true, + team: true, + hero: true, + banOrder: true, + }, + orderBy: { banOrder: "asc" }, + }, + heroAssignments: { + select: { + heroName: true, + playerName: true, + team: true, + }, + }, + }, + orderBy: { gameNumber: "asc" }, + }, + tournament: { select: { title: true, id: true } }, + }, + }), + catch: (error) => + new DataLabelingQueryError({ + operation: `fetch match for labeling id=${matchId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("admin.matchForLabeling.fetchMatch", { + attributes: { matchId }, + }) + ); + + if (!match) { + return yield* new MatchNotFoundError({ matchId }); + } + + wideEvent.team1 = match.team1; + wideEvent.team2 = match.team2; + wideEvent.mapCount = match.maps.length; + + const vods = match.vods as { url: string; platform: string }[]; + + const [team1Roster, team2Roster] = yield* Effect.all( + [ + getRosterPlayers(match.tournament.id, match.team1FullName), + getRosterPlayers(match.tournament.id, match.team2FullName), + ], + { concurrency: 2 } + ).pipe( + Effect.withSpan("admin.matchForLabeling.fetchRosters", { + attributes: { + matchId, + tournamentId: match.tournament.id, + team1: match.team1FullName, + team2: match.team2FullName, + }, + }) + ); + + wideEvent.team1RosterSize = team1Roster.length; + wideEvent.team2RosterSize = team2Roster.length; + wideEvent.outcome = "success"; + yield* Metric.increment(matchForLabelingQuerySuccessTotal); + + return { + id: match.id, + team1: match.team1, + team1FullName: match.team1FullName, + team2: match.team2, + team2FullName: match.team2FullName, + team1Score: match.team1Score, + team2Score: match.team2Score, + matchDate: match.matchDate, + tournament: match.tournament.title, + vods, + team1Roster, + team2Roster, + maps: match.maps.map((map) => ({ + id: map.id, + gameNumber: map.gameNumber, + mapType: map.mapType, + mapName: map.mapName, + team1Score: map.team1Score, + team2Score: map.team2Score, + winner: map.winner, + team1Comp: map.team1Comp, + team2Comp: map.team2Comp, + heroBans: map.heroBans, + heroAssignments: map.heroAssignments, + })), + } satisfies MatchForLabeling; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(matchForLabelingQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("admin.getMatchForLabeling") + : Effect.logInfo("admin.getMatchForLabeling"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + matchForLabelingQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("admin.getMatchForLabeling") + ); + } + + const unlabeledMatchesCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => { + const [pageStr, pageSizeStr] = key.split(":"); + return getUnlabeledMatches(Number(pageStr), Number(pageSizeStr)).pipe( + Effect.tap(() => Metric.increment(adminCacheMissTotal)) + ); + }, + }); + + const matchForLabelingCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (matchId: number) => + getMatchForLabeling(matchId).pipe( + Effect.tap(() => Metric.increment(adminCacheMissTotal)) + ), + }); + + return { + getUnlabeledMatches: (page: number, pageSize: number) => + unlabeledMatchesCache + .get(`${page}:${pageSize}`) + .pipe(Effect.tap(() => Metric.increment(adminCacheRequestTotal))), + getMatchForLabeling: (matchId: number) => + matchForLabelingCache + .get(matchId) + .pipe(Effect.tap(() => Metric.increment(adminCacheRequestTotal))), + } satisfies DataLabelingServiceInterface; + } +); + +export const DataLabelingServiceLive = Layer.effect( + DataLabelingService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/admin/errors.ts b/src/data/admin/errors.ts new file mode 100644 index 000000000..ea23cb71d --- /dev/null +++ b/src/data/admin/errors.ts @@ -0,0 +1,24 @@ +import { Schema as S } from "effect"; + +export class DataLabelingQueryError extends S.TaggedError()( + "DataLabelingQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Data labeling query failed: ${this.operation}`; + } +} + +export class MatchNotFoundError extends S.TaggedError()( + "MatchNotFoundError", + { + matchId: S.Number, + } +) { + get message(): string { + return `Match not found: ${this.matchId}`; + } +} diff --git a/src/data/admin/index.ts b/src/data/admin/index.ts new file mode 100644 index 000000000..51d49ba47 --- /dev/null +++ b/src/data/admin/index.ts @@ -0,0 +1,16 @@ +export { + DataLabelingService, + DataLabelingServiceLive, +} from "./data-labeling-service"; +export type { DataLabelingServiceInterface } from "./data-labeling-service"; + +export { DataLabelingQueryError, MatchNotFoundError } from "./errors"; + +export type { + UnlabeledMatchSummary, + UnlabeledMatchesResult, + RosterPlayerForLabeling, + HeroAssignmentForLabeling, + MatchMapForLabeling, + MatchForLabeling, +} from "./types"; diff --git a/src/data/admin/metrics.ts b/src/data/admin/metrics.ts new file mode 100644 index 000000000..1c6754315 --- /dev/null +++ b/src/data/admin/metrics.ts @@ -0,0 +1,55 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const unlabeledMatchesQuerySuccessTotal = Metric.counter( + "admin.unlabeled_matches.query.success", + { + description: "Total successful unlabeled matches queries", + incremental: true, + } +); + +export const unlabeledMatchesQueryErrorTotal = Metric.counter( + "admin.unlabeled_matches.query.error", + { + description: "Total unlabeled matches query failures", + incremental: true, + } +); + +export const unlabeledMatchesQueryDuration = Metric.histogram( + "admin.unlabeled_matches.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of unlabeled matches query duration in milliseconds" +); + +export const matchForLabelingQuerySuccessTotal = Metric.counter( + "admin.match_for_labeling.query.success", + { + description: "Total successful match-for-labeling queries", + incremental: true, + } +); + +export const matchForLabelingQueryErrorTotal = Metric.counter( + "admin.match_for_labeling.query.error", + { + description: "Total match-for-labeling query failures", + incremental: true, + } +); + +export const matchForLabelingQueryDuration = Metric.histogram( + "admin.match_for_labeling.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of match-for-labeling query duration in milliseconds" +); + +export const adminCacheRequestTotal = Metric.counter("admin.cache.request", { + description: "Total admin cache requests", + incremental: true, +}); + +export const adminCacheMissTotal = Metric.counter("admin.cache.miss", { + description: "Total admin cache misses", + incremental: true, +}); diff --git a/src/data/admin/types.ts b/src/data/admin/types.ts new file mode 100644 index 000000000..a26074055 --- /dev/null +++ b/src/data/admin/types.ts @@ -0,0 +1,70 @@ +import type { MapType, RosterRole } from "@prisma/client"; + +export type UnlabeledMatchSummary = { + id: number; + team1: string; + team1FullName: string; + team2: string; + team2FullName: string; + team1Score: number | null; + team2Score: number | null; + matchDate: Date; + tournament: string; + vodCount: number; + labeledMaps: number; + totalMaps: number; +}; + +export type UnlabeledMatchesResult = { + matches: UnlabeledMatchSummary[]; + totalCount: number; + page: number; + pageSize: number; +}; + +export type RosterPlayerForLabeling = { + id: number; + displayName: string; + role: RosterRole; +}; + +export type HeroAssignmentForLabeling = { + heroName: string; + playerName: string; + team: string; +}; + +export type MatchMapForLabeling = { + id: number; + gameNumber: number; + mapType: MapType; + mapName: string; + team1Score: string; + team2Score: string; + winner: string; + team1Comp: string[]; + team2Comp: string[]; + heroBans: { + id: number; + team: string; + hero: string; + banOrder: number; + }[]; + heroAssignments: HeroAssignmentForLabeling[]; +}; + +export type MatchForLabeling = { + id: number; + team1: string; + team1FullName: string; + team2: string; + team2FullName: string; + team1Score: number | null; + team2Score: number | null; + matchDate: Date; + tournament: string; + vods: { url: string; platform: string }[]; + maps: MatchMapForLabeling[]; + team1Roster: RosterPlayerForLabeling[]; + team2Roster: RosterPlayerForLabeling[]; +}; diff --git a/src/data/hero/errors.ts b/src/data/hero/errors.ts new file mode 100644 index 000000000..d621449ef --- /dev/null +++ b/src/data/hero/errors.ts @@ -0,0 +1,13 @@ +import { Schema as S } from "effect"; + +export class HeroQueryError extends S.TaggedError()( + "HeroQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Hero query failed: ${this.operation}`; + } +} diff --git a/src/data/hero/index.ts b/src/data/hero/index.ts new file mode 100644 index 000000000..01ab94c16 --- /dev/null +++ b/src/data/hero/index.ts @@ -0,0 +1,6 @@ +export { HeroService, HeroServiceLive } from "./service"; +export type { HeroServiceInterface } from "./service"; + +export { HeroQueryError } from "./errors"; + +export { ScrimIdsSchema, HeroNameSchema } from "./types"; diff --git a/src/data/hero/metrics.ts b/src/data/hero/metrics.ts new file mode 100644 index 000000000..5a776d18a --- /dev/null +++ b/src/data/hero/metrics.ts @@ -0,0 +1,77 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const heroStatsQuerySuccessTotal = Metric.counter( + "hero.stats.query.success", + { + description: "Total successful hero stats queries", + incremental: true, + } +); + +export const heroStatsQueryErrorTotal = Metric.counter( + "hero.stats.query.error", + { + description: "Total hero stats query failures", + incremental: true, + } +); + +export const heroStatsQueryDuration = Metric.histogram( + "hero.stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of hero stats query duration in milliseconds" +); + +export const heroKillsQuerySuccessTotal = Metric.counter( + "hero.kills.query.success", + { + description: "Total successful hero kills queries", + incremental: true, + } +); + +export const heroKillsQueryErrorTotal = Metric.counter( + "hero.kills.query.error", + { + description: "Total hero kills query failures", + incremental: true, + } +); + +export const heroKillsQueryDuration = Metric.histogram( + "hero.kills.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of hero kills query duration in milliseconds" +); + +export const heroDeathsQuerySuccessTotal = Metric.counter( + "hero.deaths.query.success", + { + description: "Total successful hero deaths queries", + incremental: true, + } +); + +export const heroDeathsQueryErrorTotal = Metric.counter( + "hero.deaths.query.error", + { + description: "Total hero deaths query failures", + incremental: true, + } +); + +export const heroDeathsQueryDuration = Metric.histogram( + "hero.deaths.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of hero deaths query duration in milliseconds" +); + +export const heroCacheRequestTotal = Metric.counter("hero.cache.request", { + description: "Total hero cache requests", + incremental: true, +}); + +export const heroCacheMissTotal = Metric.counter("hero.cache.miss", { + description: "Total hero cache misses", + incremental: true, +}); diff --git a/src/data/hero/service.ts b/src/data/hero/service.ts new file mode 100644 index 000000000..934f9e346 --- /dev/null +++ b/src/data/hero/service.ts @@ -0,0 +1,395 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { removeDuplicateRows } from "@/lib/utils"; +import type { Kill, PlayerStat } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { HeroQueryError } from "./errors"; +import { + heroDeathsQueryDuration, + heroDeathsQueryErrorTotal, + heroDeathsQuerySuccessTotal, + heroKillsQueryDuration, + heroKillsQueryErrorTotal, + heroKillsQuerySuccessTotal, + heroStatsQueryDuration, + heroStatsQueryErrorTotal, + heroStatsQuerySuccessTotal, + heroCacheRequestTotal, + heroCacheMissTotal, +} from "./metrics"; + +export type HeroServiceInterface = { + readonly getAllStatsForHero: ( + scrimIds: number[], + hero: string + ) => Effect.Effect; + + readonly getAllKillsForHero: ( + scrimIds: number[], + hero: string + ) => Effect.Effect; + + readonly getAllDeathsForHero: ( + scrimIds: number[], + hero: string + ) => Effect.Effect; +}; + +export class HeroService extends Context.Tag("@app/data/hero/HeroService")< + HeroService, + HeroServiceInterface +>() {} + +export const make: Effect.Effect = Effect.gen( + function* () { + function resolveMapDataIds( + scrimIds: number[] + ): Effect.Effect { + return Effect.gen(function* () { + const mapDataIds = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { maps: true }, + }), + catch: (error) => + new HeroQueryError({ + operation: "fetch map data IDs from scrims", + cause: error, + }), + }); + + const mapDataIdSet = new Set(); + mapDataIds.forEach((scrim) => { + scrim.maps.forEach((map) => { + mapDataIdSet.add(map.id); + }); + }); + + return Array.from(mapDataIdSet); + }); + } + + function getAllStatsForHero( + scrimIds: number[], + hero: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + hero, + scrimCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("hero", hero); + yield* Effect.annotateCurrentSpan("scrimCount", scrimIds.length); + const mapDataIdArray = yield* resolveMapDataIds(scrimIds).pipe( + Effect.withSpan("hero.stats.resolveMapDataIds", { + attributes: { scrimCount: scrimIds.length }, + }) + ); + + wideEvent.mapDataIdCount = mapDataIdArray.length; + + if (mapDataIdArray.length === 0) { + wideEvent.outcome = "success"; + wideEvent.resultCount = 0; + yield* Metric.increment(heroStatsQuerySuccessTotal); + const _empty: PlayerStat[] = []; return _empty; + } + + const rows = yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapDataIdArray)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapDataIdArray)}) + AND ps."player_hero" ILIKE ${hero}`, + catch: (error) => + new HeroQueryError({ + operation: `fetch all stats for hero: ${hero}`, + cause: error, + }), + }).pipe( + Effect.withSpan("hero.stats.queryRaw", { + attributes: { hero, mapCount: mapDataIdArray.length }, + }) + ); + + const result = removeDuplicateRows(rows); + + wideEvent.rawRowCount = rows.length; + wideEvent.resultCount = result.length; + wideEvent.outcome = "success"; + yield* Metric.increment(heroStatsQuerySuccessTotal); + + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("hero.getAllStatsForHero") + : Effect.logInfo("hero.getAllStatsForHero"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heroStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("hero.getAllStatsForHero") + ); + } + + function getAllKillsForHero( + scrimIds: number[], + hero: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + hero, + scrimCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("hero", hero); + yield* Effect.annotateCurrentSpan("scrimCount", scrimIds.length); + const mapDataIdArray = yield* resolveMapDataIds(scrimIds).pipe( + Effect.withSpan("hero.kills.resolveMapDataIds", { + attributes: { scrimCount: scrimIds.length }, + }) + ); + + wideEvent.mapDataIdCount = mapDataIdArray.length; + + if (mapDataIdArray.length === 0) { + wideEvent.outcome = "success"; + wideEvent.resultCount = 0; + yield* Metric.increment(heroKillsQuerySuccessTotal); + const _empty: Kill[] = []; return _empty; + } + + const kills = yield* Effect.tryPromise({ + try: () => + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIdArray }, + attacker_hero: { equals: hero, mode: "insensitive" }, + }, + }), + catch: (error) => + new HeroQueryError({ + operation: `fetch all kills for hero: ${hero}`, + cause: error, + }), + }).pipe( + Effect.withSpan("hero.kills.query", { + attributes: { hero, mapCount: mapDataIdArray.length }, + }) + ); + + wideEvent.resultCount = kills.length; + wideEvent.outcome = "success"; + yield* Metric.increment(heroKillsQuerySuccessTotal); + + return kills; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroKillsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("hero.getAllKillsForHero") + : Effect.logInfo("hero.getAllKillsForHero"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heroKillsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("hero.getAllKillsForHero") + ); + } + + function getAllDeathsForHero( + scrimIds: number[], + hero: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + hero, + scrimCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("hero", hero); + yield* Effect.annotateCurrentSpan("scrimCount", scrimIds.length); + const mapDataIdArray = yield* resolveMapDataIds(scrimIds).pipe( + Effect.withSpan("hero.deaths.resolveMapDataIds", { + attributes: { scrimCount: scrimIds.length }, + }) + ); + + wideEvent.mapDataIdCount = mapDataIdArray.length; + + if (mapDataIdArray.length === 0) { + wideEvent.outcome = "success"; + wideEvent.resultCount = 0; + yield* Metric.increment(heroDeathsQuerySuccessTotal); + const _empty: Kill[] = []; return _empty; + } + + const deaths = yield* Effect.tryPromise({ + try: () => + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIdArray }, + victim_hero: { equals: hero, mode: "insensitive" }, + }, + }), + catch: (error) => + new HeroQueryError({ + operation: `fetch all deaths for hero: ${hero}`, + cause: error, + }), + }).pipe( + Effect.withSpan("hero.deaths.query", { + attributes: { hero, mapCount: mapDataIdArray.length }, + }) + ); + + wideEvent.resultCount = deaths.length; + wideEvent.outcome = "success"; + yield* Metric.increment(heroDeathsQuerySuccessTotal); + + return deaths; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroDeathsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("hero.getAllDeathsForHero") + : Effect.logInfo("hero.getAllDeathsForHero"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + heroDeathsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("hero.getAllDeathsForHero") + ); + } + + function heroCacheKeyOf(scrimIds: number[], hero: string) { + return `${JSON.stringify(scrimIds)}:${hero}`; + } + + const statsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => { + const colonIdx = key.lastIndexOf(":"); + const scrimIds = JSON.parse(key.slice(0, colonIdx)) as number[]; + const hero = key.slice(colonIdx + 1); + return getAllStatsForHero(scrimIds, hero).pipe( + Effect.tap(() => Metric.increment(heroCacheMissTotal)) + ); + }, + }); + + const killsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => { + const colonIdx = key.lastIndexOf(":"); + const scrimIds = JSON.parse(key.slice(0, colonIdx)) as number[]; + const hero = key.slice(colonIdx + 1); + return getAllKillsForHero(scrimIds, hero).pipe( + Effect.tap(() => Metric.increment(heroCacheMissTotal)) + ); + }, + }); + + const deathsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => { + const colonIdx = key.lastIndexOf(":"); + const scrimIds = JSON.parse(key.slice(0, colonIdx)) as number[]; + const hero = key.slice(colonIdx + 1); + return getAllDeathsForHero(scrimIds, hero).pipe( + Effect.tap(() => Metric.increment(heroCacheMissTotal)) + ); + }, + }); + + return { + getAllStatsForHero: (scrimIds: number[], hero: string) => + statsCache + .get(heroCacheKeyOf(scrimIds, hero)) + .pipe(Effect.tap(() => Metric.increment(heroCacheRequestTotal))), + getAllKillsForHero: (scrimIds: number[], hero: string) => + killsCache + .get(heroCacheKeyOf(scrimIds, hero)) + .pipe(Effect.tap(() => Metric.increment(heroCacheRequestTotal))), + getAllDeathsForHero: (scrimIds: number[], hero: string) => + deathsCache + .get(heroCacheKeyOf(scrimIds, hero)) + .pipe(Effect.tap(() => Metric.increment(heroCacheRequestTotal))), + } satisfies HeroServiceInterface; + } +); + +export const HeroServiceLive = Layer.effect(HeroService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/hero/types.ts b/src/data/hero/types.ts new file mode 100644 index 000000000..352968dc3 --- /dev/null +++ b/src/data/hero/types.ts @@ -0,0 +1,12 @@ +import { Schema as S } from "effect"; + +export const ScrimIdsSchema = S.Array( + S.Number.pipe( + S.int(), + S.positive({ message: () => "Scrim ID must be a positive integer" }) + ) +).pipe(S.minItems(1, { message: () => "At least one scrim ID is required" })); + +export const HeroNameSchema = S.String.pipe( + S.minLength(1, { message: () => "Hero name must not be empty" }) +); diff --git a/src/data/tournament/broadcast-service.ts b/src/data/tournament/broadcast-service.ts new file mode 100644 index 000000000..570eaee41 --- /dev/null +++ b/src/data/tournament/broadcast-service.ts @@ -0,0 +1,388 @@ +import { aggregatePlayerStats } from "@/data/comparison/computation"; +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { removeDuplicateRows } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { heroRoleMapping } from "@/types/heroes"; +import { Prisma, type PlayerStat } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { BroadcastQueryError } from "./errors"; +import { + getBroadcastDataDuration, + getBroadcastDataErrorTotal, + getBroadcastDataSuccessTotal, + broadcastCacheRequestTotal, + broadcastCacheMissTotal, +} from "./metrics"; + +function findTournamentWithRelations(id: number) { + return prisma.tournament.findUnique({ + where: { id }, + include: { + teams: { + include: { team: { select: { id: true, name: true, image: true } } }, + orderBy: { seed: "asc" }, + }, + rounds: { orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }] }, + matches: { + include: { + team1: true, + team2: true, + winner: true, + round: true, + maps: { include: { map: true }, orderBy: { gameNumber: "asc" } }, + }, + orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], + }, + }, + }); +} + +type TournamentWithRelations = NonNullable< + Awaited> +>; + +function formatTournamentMeta(tournament: TournamentWithRelations) { + return { + id: tournament.id, + name: tournament.name, + format: tournament.format, + bestOf: tournament.bestOf, + status: tournament.status, + teams: tournament.teams.map((t) => ({ + name: t.name, + seed: t.seed, + image: t.team?.image ?? null, + eliminated: t.eliminated, + })), + matches: tournament.matches.map((m) => ({ + id: m.id, + round: m.round.roundName, + bracket: m.round.bracket, + bracketPosition: m.bracketPosition, + team1: m.team1 ? { name: m.team1.name, score: m.team1Score } : null, + team2: m.team2 ? { name: m.team2.name, score: m.team2Score } : null, + status: m.status, + winner: m.winner?.name ?? null, + })), + }; +} + +type BroadcastResult = { + tournament: ReturnType; + players: { + name: string; + team: string; + role: string; + heroesPlayed: string[]; + mapsPlayed: number; + stats: Record; + per10: Record; + averages: Record; + }[]; +} | null; + +export type BroadcastServiceInterface = { + readonly getTournamentBroadcastData: ( + tournamentId: number + ) => Effect.Effect; +}; + +export class BroadcastService extends Context.Tag( + "@app/data/tournament/BroadcastService" +)() {} + +export const make: Effect.Effect = Effect.gen( + function* () { + function getTournamentBroadcastData( + tournamentId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { tournamentId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("tournamentId", tournamentId); + const tournament = yield* Effect.tryPromise({ + try: () => findTournamentWithRelations(tournamentId), + catch: (error) => + new BroadcastQueryError({ + operation: `fetch tournament with relations for broadcast: ${tournamentId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.broadcast.findTournament", { + attributes: { tournamentId }, + }) + ); + + if (!tournament) { + wideEvent.found = false; + wideEvent.outcome = "success"; + yield* Metric.increment(getBroadcastDataSuccessTotal); + return null; + } + + wideEvent.found = true; + + const scrimIds = tournament.matches + .map((m) => m.scrimId) + .filter((id): id is number => id !== null); + + wideEvent.scrim_count = scrimIds.length; + + if (scrimIds.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(getBroadcastDataSuccessTotal); + return { + tournament: formatTournamentMeta(tournament), + players: [], + }; + } + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { + maps: { select: { mapData: { select: { id: true } } } }, + }, + }), + catch: (error) => + new BroadcastQueryError({ + operation: "fetch scrims for broadcast map data ids", + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.broadcast.fetchScrims", { + attributes: { tournamentId, scrimCount: scrimIds.length }, + }) + ); + + const mapIds: number[] = []; + for (const scrim of scrims) { + for (const map of scrim.maps) { + for (const md of map.mapData) { + mapIds.push(md.id); + } + } + } + + wideEvent.map_data_count = mapIds.length; + + if (mapIds.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(getBroadcastDataSuccessTotal); + return { + tournament: formatTournamentMeta(tournament), + players: [], + }; + } + + const allStats = yield* Effect.tryPromise({ + try: async () => + removeDuplicateRows( + await prisma.$queryRaw` + WITH maxTime AS ( + SELECT MAX("match_time") AS max_time, "MapDataId" + FROM "PlayerStat" + WHERE "MapDataId" IN (${Prisma.join(mapIds)}) + GROUP BY "MapDataId" + ) + SELECT ps.* FROM "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE ps."MapDataId" IN (${Prisma.join(mapIds)}) + ` + ), + catch: (error) => + new BroadcastQueryError({ + operation: "fetch player stats for broadcast", + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.broadcast.fetchPlayerStats", { + attributes: { tournamentId, mapCount: mapIds.length }, + }) + ); + + const allCalcStats = yield* Effect.tryPromise({ + try: () => + prisma.calculatedStat.findMany({ + where: { MapDataId: { in: mapIds } }, + }), + catch: (error) => + new BroadcastQueryError({ + operation: "fetch calculated stats for broadcast", + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.broadcast.fetchCalcStats", { + attributes: { tournamentId, mapCount: mapIds.length }, + }) + ); + + wideEvent.player_stat_count = allStats.length; + wideEvent.calc_stat_count = allCalcStats.length; + + const playerStatsMap = new Map(); + for (const stat of allStats) { + const name = stat.player_name; + if (!playerStatsMap.has(name)) { + playerStatsMap.set(name, []); + } + playerStatsMap.get(name)!.push(stat); + } + + const playerCalcStatsMap = new Map< + string, + (typeof allCalcStats)[number][] + >(); + for (const stat of allCalcStats) { + const name = stat.playerName; + if (!playerCalcStatsMap.has(name)) { + playerCalcStatsMap.set(name, []); + } + playerCalcStatsMap.get(name)!.push(stat); + } + + const players = Array.from(playerStatsMap.entries()).map( + ([name, stats]) => { + const calcStats = playerCalcStatsMap.get(name) ?? []; + const aggregated = aggregatePlayerStats(stats, calcStats); + + const heroTimeTotals = new Map(); + for (const stat of stats) { + heroTimeTotals.set( + stat.player_hero, + (heroTimeTotals.get(stat.player_hero) ?? 0) + + stat.hero_time_played + ); + } + const heroesPlayed = Array.from(heroTimeTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([hero]) => hero); + const primaryHero = heroesPlayed[0] ?? "Unknown"; + const role = heroRoleMapping[primaryHero as HeroName] ?? "Damage"; + + const playerTeam = stats[0]?.player_team ?? "Unknown"; + + const mapsPlayed = new Set(stats.map((s) => s.MapDataId)).size; + + return { + name, + team: playerTeam, + role, + heroesPlayed, + mapsPlayed, + stats: { + eliminations: aggregated.eliminations, + finalBlows: aggregated.finalBlows, + deaths: aggregated.deaths, + heroDamageDone: aggregated.heroDamageDealt, + healingDone: aggregated.healingDealt, + damageBlocked: aggregated.damageBlocked, + damageTaken: aggregated.damageTaken, + offensiveAssists: aggregated.offensiveAssists, + defensiveAssists: aggregated.defensiveAssists, + ultimatesUsed: aggregated.ultimatesUsed, + ultimatesEarned: aggregated.ultimatesEarned, + multikills: aggregated.multikills, + multikillBest: aggregated.multikillBest, + soloKills: aggregated.soloKills, + objectiveKills: aggregated.objectiveKills, + environmentalKills: aggregated.environmentalKills, + criticalHits: aggregated.criticalHits, + weaponAccuracy: aggregated.weaponAccuracy, + criticalHitAccuracy: aggregated.criticalHitAccuracy, + scopedAccuracy: aggregated.scopedAccuracy, + heroTimePlayed: aggregated.heroTimePlayed, + }, + per10: { + eliminationsPer10: aggregated.eliminationsPer10, + finalBlowsPer10: aggregated.finalBlowsPer10, + deathsPer10: aggregated.deathsPer10, + heroDamagePer10: aggregated.heroDamagePer10, + healingPer10: aggregated.healingDealtPer10, + damageBlockedPer10: aggregated.damageBlockedPer10, + damageTakenPer10: aggregated.damageTakenPer10, + ultimatesUsedPer10: aggregated.ultimatesUsedPer10, + soloKillsPer10: aggregated.soloKillsPer10, + offensiveAssistsPer10: aggregated.offensiveAssistsPer10, + defensiveAssistsPer10: aggregated.defensiveAssistsPer10, + objectiveKillsPer10: aggregated.objectiveKillsPer10, + }, + averages: { + avgKD: + aggregated.deaths > 0 + ? aggregated.finalBlows / aggregated.deaths + : aggregated.finalBlows, + avgMvpScore: aggregated.mvpScore, + avgFirstPickPct: aggregated.firstPickPercentage, + avgFirstDeathPct: aggregated.firstDeathPercentage, + avgDuelWinratePct: aggregated.duelWinratePercentage, + firstPickCount: aggregated.firstPickCount, + firstDeathCount: aggregated.firstDeathCount, + avgUltChargeTime: aggregated.averageUltChargeTime, + avgKillsPerUltimate: aggregated.killsPerUltimate, + }, + }; + } + ); + + wideEvent.player_count = players.length; + wideEvent.outcome = "success"; + yield* Metric.increment(getBroadcastDataSuccessTotal); + + return { + tournament: formatTournamentMeta(tournament), + players, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getBroadcastDataErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getTournamentBroadcastData") + : Effect.logInfo("tournament.getTournamentBroadcastData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + getBroadcastDataDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("tournament.getTournamentBroadcastData") + ); + } + + const broadcastCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (tournamentId: number) => + getTournamentBroadcastData(tournamentId).pipe( + Effect.tap(() => Metric.increment(broadcastCacheMissTotal)) + ), + }); + + return { + getTournamentBroadcastData: (tournamentId: number) => + broadcastCache + .get(tournamentId) + .pipe(Effect.tap(() => Metric.increment(broadcastCacheRequestTotal))), + } satisfies BroadcastServiceInterface; + } +); + +export const BroadcastServiceLive = Layer.effect(BroadcastService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/tournament/errors.ts b/src/data/tournament/errors.ts new file mode 100644 index 000000000..cb68520b4 --- /dev/null +++ b/src/data/tournament/errors.ts @@ -0,0 +1,47 @@ +import { Schema as S } from "effect"; + +export class TournamentNotFoundError extends S.TaggedError()( + "TournamentNotFoundError", + { + tournamentId: S.Number, + } +) { + get message(): string { + return `Tournament not found: ${this.tournamentId}`; + } +} + +export class TournamentQueryError extends S.TaggedError()( + "TournamentQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Tournament query failed: ${this.operation}`; + } +} + +export class TournamentMatchNotFoundError extends S.TaggedError()( + "TournamentMatchNotFoundError", + { + matchId: S.Number, + } +) { + get message(): string { + return `Tournament match not found: ${this.matchId}`; + } +} + +export class BroadcastQueryError extends S.TaggedError()( + "BroadcastQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Broadcast query failed: ${this.operation}`; + } +} diff --git a/src/data/tournament/index.ts b/src/data/tournament/index.ts new file mode 100644 index 000000000..bc7b51dba --- /dev/null +++ b/src/data/tournament/index.ts @@ -0,0 +1,14 @@ +export { TournamentService, TournamentServiceLive } from "./tournament-service"; +export type { TournamentServiceInterface } from "./tournament-service"; + +export { BroadcastService, BroadcastServiceLive } from "./broadcast-service"; +export type { BroadcastServiceInterface } from "./broadcast-service"; + +export { + TournamentNotFoundError, + TournamentQueryError, + TournamentMatchNotFoundError, + BroadcastQueryError, +} from "./errors"; + +export type { BroadcastData } from "./types"; diff --git a/src/data/tournament/metrics.ts b/src/data/tournament/metrics.ts new file mode 100644 index 000000000..f774f85cc --- /dev/null +++ b/src/data/tournament/metrics.ts @@ -0,0 +1,162 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const getTournamentSuccessTotal = Metric.counter( + "tournament.get_tournament.query.success", + { + description: "Total successful getTournament queries", + incremental: true, + } +); + +export const getTournamentErrorTotal = Metric.counter( + "tournament.get_tournament.query.error", + { + description: "Total getTournament query failures", + incremental: true, + } +); + +export const getTournamentDuration = Metric.histogram( + "tournament.get_tournament.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getTournament query duration in milliseconds" +); + +export const getUserTournamentsSuccessTotal = Metric.counter( + "tournament.get_user_tournaments.query.success", + { + description: "Total successful getUserTournaments queries", + incremental: true, + } +); + +export const getUserTournamentsErrorTotal = Metric.counter( + "tournament.get_user_tournaments.query.error", + { + description: "Total getUserTournaments query failures", + incremental: true, + } +); + +export const getUserTournamentsDuration = Metric.histogram( + "tournament.get_user_tournaments.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getUserTournaments query duration in milliseconds" +); + +export const getTournamentMatchSuccessTotal = Metric.counter( + "tournament.get_tournament_match.query.success", + { + description: "Total successful getTournamentMatch queries", + incremental: true, + } +); + +export const getTournamentMatchErrorTotal = Metric.counter( + "tournament.get_tournament_match.query.error", + { + description: "Total getTournamentMatch query failures", + incremental: true, + } +); + +export const getTournamentMatchDuration = Metric.histogram( + "tournament.get_tournament_match.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getTournamentMatch query duration in milliseconds" +); + +export const getTournamentBracketSuccessTotal = Metric.counter( + "tournament.get_tournament_bracket.query.success", + { + description: "Total successful getTournamentBracket queries", + incremental: true, + } +); + +export const getTournamentBracketErrorTotal = Metric.counter( + "tournament.get_tournament_bracket.query.error", + { + description: "Total getTournamentBracket query failures", + incremental: true, + } +); + +export const getTournamentBracketDuration = Metric.histogram( + "tournament.get_tournament_bracket.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getTournamentBracket query duration in milliseconds" +); + +export const getRRStandingsSuccessTotal = Metric.counter( + "tournament.get_rr_standings.query.success", + { + description: "Total successful getRRStandings queries", + incremental: true, + } +); + +export const getRRStandingsErrorTotal = Metric.counter( + "tournament.get_rr_standings.query.error", + { + description: "Total getRRStandings query failures", + incremental: true, + } +); + +export const getRRStandingsDuration = Metric.histogram( + "tournament.get_rr_standings.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getRRStandings query duration in milliseconds" +); + +export const getBroadcastDataSuccessTotal = Metric.counter( + "tournament.get_broadcast_data.query.success", + { + description: "Total successful getTournamentBroadcastData queries", + incremental: true, + } +); + +export const getBroadcastDataErrorTotal = Metric.counter( + "tournament.get_broadcast_data.query.error", + { + description: "Total getTournamentBroadcastData query failures", + incremental: true, + } +); + +export const getBroadcastDataDuration = Metric.histogram( + "tournament.get_broadcast_data.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getTournamentBroadcastData query duration in milliseconds" +); + +export const tournamentCacheRequestTotal = Metric.counter( + "tournament.cache.request", + { + description: "Total tournament cache requests", + incremental: true, + } +); + +export const tournamentCacheMissTotal = Metric.counter( + "tournament.cache.miss", + { + description: "Total tournament cache misses", + incremental: true, + } +); + +export const broadcastCacheRequestTotal = Metric.counter( + "broadcast.cache.request", + { + description: "Total broadcast cache requests", + incremental: true, + } +); + +export const broadcastCacheMissTotal = Metric.counter("broadcast.cache.miss", { + description: "Total broadcast cache misses", + incremental: true, +}); diff --git a/src/data/tournament/tournament-service.ts b/src/data/tournament/tournament-service.ts new file mode 100644 index 000000000..ff9135904 --- /dev/null +++ b/src/data/tournament/tournament-service.ts @@ -0,0 +1,530 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { + calculateRRStandings, + type TeamStanding, +} from "@/lib/tournaments/round-robin"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { TournamentQueryError } from "./errors"; +import { + getRRStandingsDuration, + getRRStandingsErrorTotal, + getRRStandingsSuccessTotal, + getTournamentBracketDuration, + getTournamentBracketErrorTotal, + getTournamentBracketSuccessTotal, + getTournamentDuration, + getTournamentErrorTotal, + getTournamentMatchDuration, + getTournamentMatchErrorTotal, + getTournamentMatchSuccessTotal, + getTournamentSuccessTotal, + getUserTournamentsDuration, + getUserTournamentsErrorTotal, + getUserTournamentsSuccessTotal, + tournamentCacheRequestTotal, + tournamentCacheMissTotal, +} from "./metrics"; + +type GetTournamentResult = Awaited< + ReturnType +>; + +type GetUserTournamentsResult = Awaited< + ReturnType +>; + +type GetTournamentMatchResult = Awaited< + ReturnType +>; + +type GetTournamentBracketResult = Awaited< + ReturnType +>; + +export type TournamentServiceInterface = { + readonly getTournament: ( + id: number + ) => Effect.Effect; + + readonly getUserTournaments: ( + userId: string + ) => Effect.Effect; + + readonly getTournamentMatch: ( + matchId: number + ) => Effect.Effect; + + readonly getTournamentBracket: ( + tournamentId: number + ) => Effect.Effect; + + readonly getRRStandings: ( + tournamentId: number + ) => Effect.Effect; +}; + +export class TournamentService extends Context.Tag( + "@app/data/tournament/TournamentService" +)() {} + +export const make: Effect.Effect = Effect.gen( + function* () { + function getTournament( + id: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { tournamentId: id }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("tournamentId", id); + const result = yield* Effect.tryPromise({ + try: () => + prisma.tournament.findUnique({ + where: { id }, + include: { + teams: { + include: { + team: { select: { id: true, name: true, image: true } }, + }, + orderBy: { seed: "asc" }, + }, + rounds: { + orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }], + }, + matches: { + include: { + team1: true, + team2: true, + winner: true, + round: true, + maps: { + include: { map: true }, + orderBy: { gameNumber: "asc" }, + }, + }, + orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], + }, + }, + }), + catch: (error) => + new TournamentQueryError({ + operation: `fetch tournament with id: ${id}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.getTournament.query", { + attributes: { tournamentId: id }, + }) + ); + + wideEvent.found = result !== null; + wideEvent.outcome = "success"; + yield* Metric.increment(getTournamentSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getTournamentErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getTournament") + : Effect.logInfo("tournament.getTournament"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(getTournamentDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("tournament.getTournament") + ); + } + + function getUserTournaments( + userId: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { userId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userId", userId); + const result = yield* Effect.tryPromise({ + try: () => + prisma.tournament.findMany({ + where: { + OR: [ + { creatorId: userId }, + { + teams: { + some: { team: { users: { some: { id: userId } } } }, + }, + }, + ], + }, + include: { + teams: { select: { name: true }, orderBy: { seed: "asc" } }, + _count: { select: { matches: true } }, + }, + orderBy: { createdAt: "desc" }, + }), + catch: (error) => + new TournamentQueryError({ + operation: `fetch tournaments for user: ${userId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.getUserTournaments.query", { + attributes: { userId }, + }) + ); + + wideEvent.count = result.length; + wideEvent.outcome = "success"; + yield* Metric.increment(getUserTournamentsSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(getUserTournamentsErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getUserTournaments") + : Effect.logInfo("tournament.getUserTournaments"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + getUserTournamentsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("tournament.getUserTournaments") + ); + } + + function getTournamentMatch( + matchId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { matchId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("matchId", matchId); + const result = yield* Effect.tryPromise({ + try: () => + prisma.tournamentMatch.findUnique({ + where: { id: matchId }, + include: { + tournament: true, + round: true, + team1: { + include: { + team: { select: { id: true, name: true, image: true } }, + }, + }, + team2: { + include: { + team: { select: { id: true, name: true, image: true } }, + }, + }, + winner: true, + maps: { + include: { + map: { + include: { + mapData: { + include: { + match_start: true, + match_end: true, + round_end: true, + HeroBan: true, + }, + }, + }, + }, + }, + orderBy: { gameNumber: "asc" }, + }, + }, + }), + catch: (error) => + new TournamentQueryError({ + operation: `fetch tournament match with id: ${matchId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.getTournamentMatch.query", { + attributes: { matchId }, + }) + ); + + wideEvent.found = result !== null; + wideEvent.outcome = "success"; + yield* Metric.increment(getTournamentMatchSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(getTournamentMatchErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getTournamentMatch") + : Effect.logInfo("tournament.getTournamentMatch"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + getTournamentMatchDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("tournament.getTournamentMatch") + ); + } + + function getTournamentBracket( + tournamentId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { tournamentId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("tournamentId", tournamentId); + const result = yield* Effect.tryPromise({ + try: () => + prisma.tournament.findUnique({ + where: { id: tournamentId }, + include: { + teams: { orderBy: { seed: "asc" } }, + rounds: { + orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }], + }, + matches: { + include: { + team1: { select: { id: true, name: true, seed: true } }, + team2: { select: { id: true, name: true, seed: true } }, + winner: { select: { id: true, name: true } }, + round: { + select: { + roundNumber: true, + roundName: true, + bracket: true, + }, + }, + }, + orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], + }, + }, + }), + catch: (error) => + new TournamentQueryError({ + operation: `fetch tournament bracket for id: ${tournamentId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.getTournamentBracket.query", { + attributes: { tournamentId }, + }) + ); + + wideEvent.found = result !== null; + wideEvent.outcome = "success"; + yield* Metric.increment(getTournamentBracketSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(getTournamentBracketErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getTournamentBracket") + : Effect.logInfo("tournament.getTournamentBracket"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + getTournamentBracketDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("tournament.getTournamentBracket") + ); + } + + function getRRStandings( + tournamentId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { tournamentId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("tournamentId", tournamentId); + const result = yield* Effect.tryPromise({ + try: () => calculateRRStandings(tournamentId), + catch: (error) => + new TournamentQueryError({ + operation: `calculate round-robin standings for tournament: ${tournamentId}`, + cause: error, + }), + }).pipe( + Effect.withSpan("tournament.getRRStandings.query", { + attributes: { tournamentId }, + }) + ); + + wideEvent.team_count = result.length; + wideEvent.outcome = "success"; + yield* Metric.increment(getRRStandingsSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getRRStandingsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("tournament.getRRStandings") + : Effect.logInfo("tournament.getRRStandings"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(getRRStandingsDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("tournament.getRRStandings") + ); + } + + const getTournamentCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (id: number) => + getTournament(id).pipe( + Effect.tap(() => Metric.increment(tournamentCacheMissTotal)) + ), + }); + + const getUserTournamentsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (userId: string) => + getUserTournaments(userId).pipe( + Effect.tap(() => Metric.increment(tournamentCacheMissTotal)) + ), + }); + + const getTournamentMatchCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (matchId: number) => + getTournamentMatch(matchId).pipe( + Effect.tap(() => Metric.increment(tournamentCacheMissTotal)) + ), + }); + + const getTournamentBracketCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (tournamentId: number) => + getTournamentBracket(tournamentId).pipe( + Effect.tap(() => Metric.increment(tournamentCacheMissTotal)) + ), + }); + + const getRRStandingsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (tournamentId: number) => + getRRStandings(tournamentId).pipe( + Effect.tap(() => Metric.increment(tournamentCacheMissTotal)) + ), + }); + + return { + getTournament: (id: number) => + getTournamentCache + .get(id) + .pipe( + Effect.tap(() => Metric.increment(tournamentCacheRequestTotal)) + ), + getUserTournaments: (userId: string) => + getUserTournamentsCache + .get(userId) + .pipe( + Effect.tap(() => Metric.increment(tournamentCacheRequestTotal)) + ), + getTournamentMatch: (matchId: number) => + getTournamentMatchCache + .get(matchId) + .pipe( + Effect.tap(() => Metric.increment(tournamentCacheRequestTotal)) + ), + getTournamentBracket: (tournamentId: number) => + getTournamentBracketCache + .get(tournamentId) + .pipe( + Effect.tap(() => Metric.increment(tournamentCacheRequestTotal)) + ), + getRRStandings: (tournamentId: number) => + getRRStandingsCache + .get(tournamentId) + .pipe( + Effect.tap(() => Metric.increment(tournamentCacheRequestTotal)) + ), + } satisfies TournamentServiceInterface; + } +); + +export const TournamentServiceLive = Layer.effect(TournamentService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/tournament/types.ts b/src/data/tournament/types.ts new file mode 100644 index 000000000..da187641a --- /dev/null +++ b/src/data/tournament/types.ts @@ -0,0 +1,54 @@ +import type { PrismaClient } from "@prisma/client"; +import type { calculateRRStandings } from "@/lib/tournaments/round-robin"; + +export type Tournament = NonNullable< + Awaited> +>; + +export type UserTournaments = Awaited< + ReturnType +>; + +export type TournamentMatch = NonNullable< + Awaited> +>; + +export type TeamStanding = Awaited< + ReturnType +>[number]; + +export type BroadcastData = { + tournament: { + id: number; + name: string; + format: string; + bestOf: number; + status: string; + teams: { + name: string; + seed: number; + image: string | null; + eliminated: boolean; + }[]; + matches: { + id: number; + round: string; + bracket: string; + bracketPosition: number; + team1: { name: string; score: number } | null; + team2: { name: string; score: number } | null; + status: string; + winner: string | null; + }[]; + }; + players: { + name: string; + team: string; + role: string; + heroesPlayed: string[]; + mapsPlayed: number; + stats: Record; + per10: Record; + averages: Record; + }[]; +} | null; diff --git a/src/data/user/errors.ts b/src/data/user/errors.ts new file mode 100644 index 000000000..d9201b544 --- /dev/null +++ b/src/data/user/errors.ts @@ -0,0 +1,24 @@ +import { Schema as S } from "effect"; + +export class UserNotFoundError extends S.TaggedError()( + "UserNotFoundError", + { + email: S.String, + } +) { + get message(): string { + return `User not found: ${this.email}`; + } +} + +export class UserQueryError extends S.TaggedError()( + "UserQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `User query failed: ${this.operation}`; + } +} diff --git a/src/data/user/index.ts b/src/data/user/index.ts new file mode 100644 index 000000000..7f9766a89 --- /dev/null +++ b/src/data/user/index.ts @@ -0,0 +1,6 @@ +export { UserService, UserServiceLive } from "./service"; +export type { UserServiceInterface } from "./service"; + +export { UserNotFoundError, UserQueryError } from "./errors"; + +export { EmailSchema } from "./types"; diff --git a/src/data/user/metrics.ts b/src/data/user/metrics.ts new file mode 100644 index 000000000..8dabc48e3 --- /dev/null +++ b/src/data/user/metrics.ts @@ -0,0 +1,71 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const getUserSuccessTotal = Metric.counter("user.getUser.success", { + description: "Total successful getUser queries", + incremental: true, +}); + +export const getUserErrorTotal = Metric.counter("user.getUser.error", { + description: "Total getUser query failures", + incremental: true, +}); + +export const getUserDuration = Metric.histogram( + "user.getUser.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getUser query duration in milliseconds" +); + +export const getTeamsSuccessTotal = Metric.counter( + "user.getTeamsWithPerms.success", + { + description: "Total successful getTeamsWithPerms queries", + incremental: true, + } +); + +export const getTeamsErrorTotal = Metric.counter( + "user.getTeamsWithPerms.error", + { + description: "Total getTeamsWithPerms query failures", + incremental: true, + } +); + +export const getTeamsDuration = Metric.histogram( + "user.getTeamsWithPerms.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getTeamsWithPerms query duration in milliseconds" +); + +export const getAppSettingsSuccessTotal = Metric.counter( + "user.getAppSettings.success", + { + description: "Total successful getAppSettings queries", + incremental: true, + } +); + +export const getAppSettingsErrorTotal = Metric.counter( + "user.getAppSettings.error", + { + description: "Total getAppSettings query failures", + incremental: true, + } +); + +export const getAppSettingsDuration = Metric.histogram( + "user.getAppSettings.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getAppSettings query duration in milliseconds" +); + +export const userCacheRequestTotal = Metric.counter("user.cache.request", { + description: "Total user cache requests", + incremental: true, +}); + +export const userCacheMissTotal = Metric.counter("user.cache.miss", { + description: "Total user cache misses", + incremental: true, +}); diff --git a/src/data/user/service.ts b/src/data/user/service.ts new file mode 100644 index 000000000..de1946c7d --- /dev/null +++ b/src/data/user/service.ts @@ -0,0 +1,289 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { AppSettings, Team, User } from "@prisma/client"; +import { $Enums } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { UserQueryError } from "./errors"; +import { + getAppSettingsDuration, + getAppSettingsErrorTotal, + getAppSettingsSuccessTotal, + getTeamsDuration, + getTeamsErrorTotal, + getTeamsSuccessTotal, + getUserDuration, + getUserErrorTotal, + getUserSuccessTotal, + userCacheRequestTotal, + userCacheMissTotal, +} from "./metrics"; + +export type UserServiceInterface = { + readonly getUser: ( + email: string | undefined + ) => Effect.Effect; + + readonly getTeamsWithPerms: ( + email: string | undefined + ) => Effect.Effect; + + readonly getAppSettings: ( + email: string | undefined + ) => Effect.Effect; +}; + +export class UserService extends Context.Tag("@app/data/user/UserService")< + UserService, + UserServiceInterface +>() {} + +export const make: Effect.Effect = Effect.gen( + function* () { + function getUser( + email: string | undefined + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + email: email ?? "undefined", + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("email", email ?? "undefined"); + if (!email) { + wideEvent.outcome = "success"; + wideEvent.found = false; + yield* Metric.increment(getUserSuccessTotal); + return null; + } + + const user = yield* Effect.tryPromise({ + try: () => prisma.user.findFirst({ where: { email } }), + catch: (error) => + new UserQueryError({ + operation: "find user by email", + cause: error, + }), + }).pipe( + Effect.withSpan("user.findByEmail", { + attributes: { email }, + }) + ); + + wideEvent.found = !!user; + wideEvent.outcome = "success"; + yield* Metric.increment(getUserSuccessTotal); + return user; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getUserErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("user.getUser") + : Effect.logInfo("user.getUser"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(getUserDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("user.getUser") + ); + } + + function getTeamsWithPerms( + email: string | undefined + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + email: email ?? "undefined", + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("email", email ?? "undefined"); + const user = yield* getUser(email); + + const teams = yield* Effect.tryPromise({ + try: () => + prisma.team.findMany({ + where: { + OR: [ + { ownerId: user?.id }, + { + users: { + some: { + id: user?.id, + role: { + in: [$Enums.UserRole.MANAGER, $Enums.UserRole.ADMIN], + }, + }, + }, + }, + { managers: { some: { userId: user?.id } } }, + ], + id: { not: 0 }, + }, + }), + catch: (error) => + new UserQueryError({ + operation: "find teams with permissions", + cause: error, + }), + }).pipe( + Effect.withSpan("user.findTeamsWithPerms", { + attributes: { userId: user?.id ?? "unknown" }, + }) + ); + + wideEvent.team_count = teams.length; + wideEvent.outcome = "success"; + yield* Metric.increment(getTeamsSuccessTotal); + return teams; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getTeamsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("user.getTeamsWithPerms") + : Effect.logInfo("user.getTeamsWithPerms"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(getTeamsDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("user.getTeamsWithPerms") + ); + } + + function getAppSettings( + email: string | undefined + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + email: email ?? "undefined", + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("email", email ?? "undefined"); + const user = yield* getUser(email); + if (!user) { + wideEvent.outcome = "success"; + wideEvent.found = false; + yield* Metric.increment(getAppSettingsSuccessTotal); + return null; + } + + const appSettings = yield* Effect.tryPromise({ + try: () => + prisma.appSettings.findFirst({ + where: { userId: user.id }, + }), + catch: (error) => + new UserQueryError({ + operation: "find app settings", + cause: error, + }), + }).pipe( + Effect.withSpan("user.findAppSettings", { + attributes: { userId: user.id }, + }) + ); + + wideEvent.found = !!appSettings; + wideEvent.outcome = "success"; + yield* Metric.increment(getAppSettingsSuccessTotal); + return appSettings; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(getAppSettingsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("user.getAppSettings") + : Effect.logInfo("user.getAppSettings"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(getAppSettingsDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("user.getAppSettings") + ); + } + + const getUserCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => + getUser(key === "__undefined__" ? undefined : key).pipe( + Effect.tap(() => Metric.increment(userCacheMissTotal)) + ), + }); + + const getTeamsWithPermsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => + getTeamsWithPerms(key === "__undefined__" ? undefined : key).pipe( + Effect.tap(() => Metric.increment(userCacheMissTotal)) + ), + }); + + const getAppSettingsCache = yield* Cache.make({ + capacity: 64, + timeToLive: Duration.seconds(30), + lookup: (key: string) => + getAppSettings(key === "__undefined__" ? undefined : key).pipe( + Effect.tap(() => Metric.increment(userCacheMissTotal)) + ), + }); + + return { + getUser: (email: string | undefined) => + getUserCache + .get(email ?? "__undefined__") + .pipe(Effect.tap(() => Metric.increment(userCacheRequestTotal))), + getTeamsWithPerms: (email: string | undefined) => + getTeamsWithPermsCache + .get(email ?? "__undefined__") + .pipe(Effect.tap(() => Metric.increment(userCacheRequestTotal))), + getAppSettings: (email: string | undefined) => + getAppSettingsCache + .get(email ?? "__undefined__") + .pipe(Effect.tap(() => Metric.increment(userCacheRequestTotal))), + } satisfies UserServiceInterface; + } +); + +export const UserServiceLive = Layer.effect(UserService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/user/types.ts b/src/data/user/types.ts new file mode 100644 index 000000000..7978108f5 --- /dev/null +++ b/src/data/user/types.ts @@ -0,0 +1,5 @@ +import { Schema as S } from "effect"; + +export const EmailSchema = S.String.pipe( + S.nonEmptyString({ message: () => "Email cannot be empty" }) +); From 8d6ebbaabf8219edf4a36f35d2eeaee71ce93abc Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:58:57 -0400 Subject: [PATCH 08/42] Add comparison Effect services Split comparison-dto into aggregation-service, trends-service, and a pure computation module. The computation module holds aggregatePlayerStats and calculateTrends as side-effect-free functions shared across domains. --- src/data/comparison/aggregation-service.ts | 718 +++++++++++++++++++++ src/data/comparison/computation.ts | 584 +++++++++++++++++ src/data/comparison/errors.ts | 25 + src/data/comparison/index.ts | 30 + src/data/comparison/metrics.ts | 99 +++ src/data/comparison/trends-service.ts | 115 ++++ src/data/comparison/types.ts | 153 +++++ 7 files changed, 1724 insertions(+) create mode 100644 src/data/comparison/aggregation-service.ts create mode 100644 src/data/comparison/computation.ts create mode 100644 src/data/comparison/errors.ts create mode 100644 src/data/comparison/index.ts create mode 100644 src/data/comparison/metrics.ts create mode 100644 src/data/comparison/trends-service.ts create mode 100644 src/data/comparison/types.ts diff --git a/src/data/comparison/aggregation-service.ts b/src/data/comparison/aggregation-service.ts new file mode 100644 index 000000000..233bacf17 --- /dev/null +++ b/src/data/comparison/aggregation-service.ts @@ -0,0 +1,718 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { removeDuplicateRows } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { + type CalculatedStat, + type MapType, + type PlayerStat, + Prisma, +} from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { + aggregatePlayerStats, + calculatePer10, + calculateTrends, +} from "./computation"; +import { ComparisonQueryError, ComparisonValidationError } from "./errors"; +import { + availableMapsDuration, + availableMapsErrorTotal, + availableMapsSuccessTotal, + comparisonCacheRequestTotal, + comparisonCacheMissTotal, + comparisonStatsDuration, + comparisonStatsErrorTotal, + comparisonStatsSuccessTotal, + teamPlayersDuration, + teamPlayersErrorTotal, + teamPlayersSuccessTotal, +} from "./metrics"; +import type { + AggregatedStats, + AvailableMap, + ComparisonStats, + GetAvailableMapsParams, + MapBreakdown, + TeamPlayer, +} from "./types"; + +export { aggregatePlayerStats } from "./computation"; + +export type ComparisonAggregationServiceInterface = { + readonly getComparisonStats: ( + mapIds: number[], + playerName: string, + heroes?: HeroName[] + ) => Effect.Effect< + ComparisonStats, + ComparisonQueryError | ComparisonValidationError + >; + + readonly getAvailableMapsForComparison: ( + params: GetAvailableMapsParams + ) => Effect.Effect; + + readonly getTeamPlayers: ( + teamId: number, + mapIds?: number[] + ) => Effect.Effect; +}; + +export class ComparisonAggregationService extends Context.Tag( + "@app/data/comparison/ComparisonAggregationService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = + Effect.gen(function* () { + function getComparisonStats( + mapIds: number[], + playerName: string, + heroes?: HeroName[] + ): Effect.Effect< + ComparisonStats, + ComparisonQueryError | ComparisonValidationError + > { + const startTime = Date.now(); + const wideEvent: Record = { + playerName, + mapIdCount: mapIds.length, + heroFilter: heroes ?? [], + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("playerName", playerName); + yield* Effect.annotateCurrentSpan("mapIdCount", mapIds.length); + if (mapIds.length === 0) { + return yield* new ComparisonValidationError({ + field: "mapIds", + reason: "At least one map must be provided", + }); + } + + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { id: { in: mapIds } }, + include: { + Scrim: true, + mapData: { + include: { + match_start: true, + }, + }, + }, + }), + catch: (error) => + new ComparisonQueryError({ + operation: "fetch maps for comparison stats", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.stats.fetchMaps", { + attributes: { mapIdCount: mapIds.length }, + }) + ); + + const mapDataIds = maps.flatMap((map) => + map.mapData.map((md) => md.id) + ); + wideEvent.mapDataIdCount = mapDataIds.length; + + if (mapDataIds.length === 0) { + return yield* new ComparisonValidationError({ + field: "mapDataIds", + reason: "No map data found for the provided map IDs", + }); + } + + const finalRoundStats = removeDuplicateRows( + yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapDataIds)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapDataIds)}) + AND ps."player_name" ILIKE ${playerName} + ${heroes && heroes.length > 0 ? Prisma.sql`AND ps."player_hero" IN (${Prisma.join(heroes)})` : Prisma.empty} + `, + catch: (error) => + new ComparisonQueryError({ + operation: "fetch final round player stats", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.stats.fetchFinalRoundStats", { + attributes: { playerName, mapDataIdCount: mapDataIds.length }, + }) + ) + ); + + wideEvent.finalRoundStatCount = finalRoundStats.length; + + const calculatedStatsWhere: Prisma.CalculatedStatWhereInput = { + MapDataId: { in: mapDataIds }, + playerName: { equals: playerName, mode: "insensitive" }, + ...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}), + }; + + const calculatedStats = yield* Effect.tryPromise({ + try: () => + prisma.calculatedStat.findMany({ + where: calculatedStatsWhere, + }), + catch: (error) => + new ComparisonQueryError({ + operation: "fetch calculated stats", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.stats.fetchCalculatedStats", { + attributes: { playerName }, + }) + ); + + wideEvent.calculatedStatCount = calculatedStats.length; + + const calculatedStatsByMapDataId: Record = {}; + calculatedStats.forEach((stat) => { + if (!calculatedStatsByMapDataId[stat.MapDataId]) { + calculatedStatsByMapDataId[stat.MapDataId] = []; + } + calculatedStatsByMapDataId[stat.MapDataId].push(stat); + }); + + const statsByMapDataId: Record = {}; + finalRoundStats.forEach((stat) => { + if (!stat.MapDataId) return; + if (!statsByMapDataId[stat.MapDataId]) { + statsByMapDataId[stat.MapDataId] = []; + } + statsByMapDataId[stat.MapDataId].push(stat); + }); + + const perMapBreakdown: MapBreakdown[] = []; + for (const map of maps) { + const mapStats: PlayerStat[] = []; + for (const md of map.mapData) { + const mdStats = statsByMapDataId[md.id] || []; + mapStats.push(...mdStats); + } + + const mapCalcStats: CalculatedStat[] = []; + for (const md of map.mapData) { + const mdStats = calculatedStatsByMapDataId[md.id] || []; + mapCalcStats.push(...mdStats); + } + + if (mapStats.length === 0) continue; + + const firstMapData = map.mapData[0]; + const matchStart = firstMapData?.match_start[0]; + const heroesPlayed = Array.from( + new Set(mapStats.map((s) => s.player_hero)) + ); + + const aggregatedMapStats = mapStats.reduce( + (acc, stat) => ({ + eliminations: acc.eliminations + stat.eliminations, + deaths: acc.deaths + stat.deaths, + all_damage_dealt: acc.all_damage_dealt + stat.all_damage_dealt, + healing_dealt: acc.healing_dealt + stat.healing_dealt, + damage_blocked: acc.damage_blocked + stat.damage_blocked, + hero_time_played: acc.hero_time_played + stat.hero_time_played, + }), + { + eliminations: 0, + deaths: 0, + all_damage_dealt: 0, + healing_dealt: 0, + damage_blocked: 0, + hero_time_played: 0, + } + ); + + const timePlayed = aggregatedMapStats.hero_time_played || 0; + const statsWithPer10 = { + ...mapStats[0], + ...aggregatedMapStats, + eliminationsPer10: calculatePer10( + aggregatedMapStats.eliminations, + timePlayed + ), + deathsPer10: calculatePer10(aggregatedMapStats.deaths, timePlayed), + allDamagePer10: calculatePer10( + aggregatedMapStats.all_damage_dealt, + timePlayed + ), + healingDealtPer10: calculatePer10( + aggregatedMapStats.healing_dealt, + timePlayed + ), + damageBlockedPer10: calculatePer10( + aggregatedMapStats.damage_blocked, + timePlayed + ), + }; + + perMapBreakdown.push({ + mapId: map.id, + mapDataId: firstMapData?.id ?? 0, + mapName: matchStart?.map_name || map.name, + mapType: matchStart?.map_type || ("Control" as MapType), + scrimId: map.scrimId ?? 0, + scrimName: map.Scrim?.name ?? "Unknown", + date: map.Scrim?.date ?? map.createdAt, + replayCode: map.replayCode, + heroes: heroesPlayed as HeroName[], + stats: statsWithPer10, + calculatedStats: mapCalcStats, + }); + } + + perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); + + const aggregated = aggregatePlayerStats( + finalRoundStats, + calculatedStats, + perMapBreakdown.map((m) => m.stats), + perMapBreakdown.map((m) => m.calculatedStats) + ); + + const trends = + perMapBreakdown.length >= 3 + ? calculateTrends( + perMapBreakdown.map((m) => m.stats), + perMapBreakdown.map((m) => m.calculatedStats) + ) + : undefined; + + let heroBreakdown: Record | undefined; + if (!heroes || heroes.length > 1) { + const heroesInvolved = Array.from( + new Set(finalRoundStats.map((s) => s.player_hero)) + ); + if (heroesInvolved.length > 1) { + heroBreakdown = {}; + for (const hero of heroesInvolved) { + const heroStats = finalRoundStats.filter( + (s) => s.player_hero === hero + ); + const heroCalcStats = calculatedStats.filter( + (s) => s.hero === hero + ); + heroBreakdown[hero] = aggregatePlayerStats( + heroStats, + heroCalcStats + ); + } + } + } + + wideEvent.perMapBreakdownCount = perMapBreakdown.length; + wideEvent.hasTrends = !!trends; + wideEvent.hasHeroBreakdown = !!heroBreakdown; + wideEvent.outcome = "success"; + yield* Metric.increment(comparisonStatsSuccessTotal); + + return { + playerName, + filteredHeroes: heroes ?? [], + mapCount: perMapBreakdown.length, + mapIds, + aggregated, + perMapBreakdown, + trends, + heroBreakdown, + } satisfies ComparisonStats; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(comparisonStatsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("comparison.getComparisonStats") + : Effect.logInfo("comparison.getComparisonStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + comparisonStatsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("comparison.getComparisonStats") + ); + } + + function getAvailableMapsForComparison( + params: GetAvailableMapsParams + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId: params.teamId, + playerName: params.playerName, + hasDateFrom: !!params.dateFrom, + hasDateTo: !!params.dateTo, + mapType: params.mapType, + heroFilter: params.heroes ?? [], + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", params.teamId); + yield* Effect.annotateCurrentSpan("playerName", params.playerName); + const { teamId, playerName, dateFrom, dateTo, mapType, heroes } = + params; + + const dateFilter = + dateFrom || dateTo + ? { + date: { + ...(dateFrom ? { gte: dateFrom } : {}), + ...(dateTo ? { lte: dateTo } : {}), + }, + } + : {}; + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { + teamId, + ...dateFilter, + }, + include: { + maps: { + include: { + mapData: { + include: { + match_start: true, + player_stat: { + where: { + player_name: { + equals: playerName, + mode: "insensitive", + }, + ...(heroes && heroes.length > 0 + ? { player_hero: { in: heroes } } + : {}), + }, + }, + }, + }, + }, + }, + }, + }), + catch: (error) => + new ComparisonQueryError({ + operation: "fetch scrims for available maps", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.availableMaps.fetchScrims", { + attributes: { teamId, playerName }, + }) + ); + + const availableMaps: AvailableMap[] = []; + + for (const scrim of scrims) { + for (const map of scrim.maps) { + const playerStats = map.mapData.flatMap((md) => md.player_stat); + if (playerStats.length === 0) continue; + + const matchStart = map.mapData[0]?.match_start[0]; + if (!matchStart) continue; + + if (mapType && matchStart.map_type !== mapType) continue; + + const heroesPlayed = Array.from( + new Set(playerStats.map((s) => s.player_hero)) + ) as HeroName[]; + + availableMaps.push({ + id: map.id, + name: matchStart.map_name, + scrimId: scrim.id, + scrimName: scrim.name, + date: scrim.date, + mapType: matchStart.map_type, + replayCode: map.replayCode, + playerHeroes: heroesPlayed, + }); + } + } + + availableMaps.sort((a, b) => b.date.getTime() - a.date.getTime()); + + wideEvent.availableMapCount = availableMaps.length; + wideEvent.outcome = "success"; + yield* Metric.increment(availableMapsSuccessTotal); + + return availableMaps; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(availableMapsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("comparison.getAvailableMapsForComparison") + : Effect.logInfo("comparison.getAvailableMapsForComparison"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(availableMapsDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("comparison.getAvailableMapsForComparison") + ); + } + + function getTeamPlayers( + teamId: number, + mapIds?: number[] + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasMapIds: !!mapIds, + mapIdCount: mapIds?.length ?? 0, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + if (mapIds && mapIds.length > 0) { + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { + id: { in: mapIds }, + Scrim: { teamId }, + }, + select: { + mapData: { + select: { + player_stat: { + select: { player_name: true }, + distinct: ["player_name"], + }, + }, + }, + }, + }), + catch: (error) => + new ComparisonQueryError({ + operation: "fetch maps for team players (filtered)", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.teamPlayers.fetchFilteredMaps", { + attributes: { teamId, mapIdCount: mapIds.length }, + }) + ); + + const playerMapCounts = new Map(); + for (const map of maps) { + for (const mapData of map.mapData) { + for (const stat of mapData.player_stat) { + const currentCount = playerMapCounts.get(stat.player_name) ?? 0; + playerMapCounts.set(stat.player_name, currentCount + 1); + } + } + } + + const players = Array.from(playerMapCounts.entries()) + .map(([name, mapCount]) => ({ name, mapCount })) + .sort((a, b) => a.name.localeCompare(b.name)); + + wideEvent.playerCount = players.length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamPlayersSuccessTotal); + return players; + } + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { teamId }, + select: { + maps: { + select: { + mapData: { + select: { + player_stat: { + select: { player_name: true }, + distinct: ["player_name"], + }, + }, + }, + }, + }, + }, + }), + catch: (error) => + new ComparisonQueryError({ + operation: "fetch scrims for team players", + cause: error, + }), + }).pipe( + Effect.withSpan("comparison.teamPlayers.fetchScrims", { + attributes: { teamId }, + }) + ); + + const playerMapCounts = new Map(); + + for (const scrim of scrims) { + for (const map of scrim.maps) { + for (const mapData of map.mapData) { + for (const stat of mapData.player_stat) { + const currentCount = playerMapCounts.get(stat.player_name) ?? 0; + playerMapCounts.set(stat.player_name, currentCount + 1); + } + } + } + } + + const players = Array.from(playerMapCounts.entries()) + .map(([name, mapCount]) => ({ name, mapCount })) + .sort((a, b) => a.name.localeCompare(b.name)); + + wideEvent.playerCount = players.length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamPlayersSuccessTotal); + return players; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(teamPlayersErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("comparison.getTeamPlayers") + : Effect.logInfo("comparison.getTeamPlayers"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(teamPlayersDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("comparison.getTeamPlayers") + ); + } + + const comparisonStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [mapIds, playerName, heroes] = JSON.parse(key) as [ + number[], + string, + HeroName[] | undefined, + ]; + return getComparisonStats(mapIds, playerName, heroes).pipe( + Effect.tap(() => Metric.increment(comparisonCacheMissTotal)) + ); + }, + }); + + const availableMapsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const params = JSON.parse(key) as GetAvailableMapsParams; + return getAvailableMapsForComparison(params).pipe( + Effect.tap(() => Metric.increment(comparisonCacheMissTotal)) + ); + }, + }); + + const teamPlayersCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamId, mapIds] = JSON.parse(key) as [ + number, + number[] | undefined, + ]; + return getTeamPlayers(teamId, mapIds).pipe( + Effect.tap(() => Metric.increment(comparisonCacheMissTotal)) + ); + }, + }); + + return { + getComparisonStats: ( + mapIds: number[], + playerName: string, + heroes?: HeroName[] + ) => + comparisonStatsCache + .get(JSON.stringify([mapIds, playerName, heroes])) + .pipe( + Effect.tap(() => Metric.increment(comparisonCacheRequestTotal)) + ), + getAvailableMapsForComparison: (params: GetAvailableMapsParams) => + availableMapsCache + .get(JSON.stringify(params)) + .pipe( + Effect.tap(() => Metric.increment(comparisonCacheRequestTotal)) + ), + getTeamPlayers: (teamId: number, mapIds?: number[]) => + teamPlayersCache + .get(JSON.stringify([teamId, mapIds])) + .pipe( + Effect.tap(() => Metric.increment(comparisonCacheRequestTotal)) + ), + } satisfies ComparisonAggregationServiceInterface; + }); + +export const ComparisonAggregationServiceLive = Layer.effect( + ComparisonAggregationService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/comparison/computation.ts b/src/data/comparison/computation.ts new file mode 100644 index 000000000..98c7e08f9 --- /dev/null +++ b/src/data/comparison/computation.ts @@ -0,0 +1,584 @@ +import { + calculateMean, + calculateStandardDeviation, +} from "@/lib/distribution-utils"; +import { + type CalculatedStat, + CalculatedStatType, + type PlayerStat, +} from "@prisma/client"; +import type { AggregatedStats, TrendsAnalysis } from "./types"; + +export function calculatePer10(value: number, timePlayed: number): number { + if (timePlayed === 0) return 0; + return (value / timePlayed) * 600; +} + +export function calculatePercentage(value: number, total: number): number { + if (total === 0) return 0; + return (value / total) * 100; +} + +function aggregateCalculatedStats( + stats: CalculatedStat[] +): Partial { + const result: Partial = { + fletaDeadliftPercentage: 0, + firstPickPercentage: 0, + firstPickCount: 0, + firstDeathPercentage: 0, + firstDeathCount: 0, + mvpScore: 0, + mapMvpCount: 0, + ajaxCount: 0, + averageUltChargeTime: 0, + averageTimeToUseUlt: 0, + averageDroughtTime: 0, + killsPerUltimate: 0, + duelWinratePercentage: 0, + fightReversalPercentage: 0, + }; + + const counts: Record = {}; + + stats.forEach((stat) => { + switch (stat.stat) { + case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) + stat.value; + counts.fletaDeadlift = (counts.fletaDeadlift ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_PERCENTAGE: + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) + stat.value; + counts.firstPick = (counts.firstPick ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_COUNT: + result.firstPickCount = (result.firstPickCount ?? 0) + stat.value; + break; + case CalculatedStatType.FIRST_DEATH_PERCENTAGE: + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) + stat.value; + counts.firstDeath = (counts.firstDeath ?? 0) + 1; + break; + case CalculatedStatType.FIRST_DEATH_COUNT: + result.firstDeathCount = (result.firstDeathCount ?? 0) + stat.value; + break; + case CalculatedStatType.MVP_SCORE: + result.mvpScore = (result.mvpScore ?? 0) + stat.value; + counts.mvpScore = (counts.mvpScore ?? 0) + 1; + break; + case CalculatedStatType.MAP_MVP_COUNT: + result.mapMvpCount = (result.mapMvpCount ?? 0) + stat.value; + break; + case CalculatedStatType.AJAX_COUNT: + result.ajaxCount = (result.ajaxCount ?? 0) + stat.value; + break; + case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) + stat.value; + counts.ultCharge = (counts.ultCharge ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_TIME_TO_USE_ULT: + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) + stat.value; + counts.timeToUseUlt = (counts.timeToUseUlt ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_DROUGHT_TIME: + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) + stat.value; + counts.drought = (counts.drought ?? 0) + 1; + break; + case CalculatedStatType.KILLS_PER_ULTIMATE: + result.killsPerUltimate = (result.killsPerUltimate ?? 0) + stat.value; + counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; + break; + case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) + stat.value; + counts.duelWinrate = (counts.duelWinrate ?? 0) + 1; + break; + case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) + stat.value; + counts.fightReversal = (counts.fightReversal ?? 0) + 1; + break; + } + }); + + if (counts.fletaDeadlift) { + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) / counts.fletaDeadlift; + } + if (counts.firstPick) { + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) / counts.firstPick; + } + if (counts.firstDeath) { + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) / counts.firstDeath; + } + if (counts.mvpScore) { + result.mvpScore = (result.mvpScore ?? 0) / counts.mvpScore; + } + if (counts.ultCharge) { + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) / counts.ultCharge; + } + if (counts.timeToUseUlt) { + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) / counts.timeToUseUlt; + } + if (counts.drought) { + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) / counts.drought; + } + if (counts.killsPerUlt) { + result.killsPerUltimate = + (result.killsPerUltimate ?? 0) / counts.killsPerUlt; + } + if (counts.duelWinrate) { + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) / counts.duelWinrate; + } + if (counts.fightReversal) { + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) / counts.fightReversal; + } + + return result; +} + +function calculateVarianceMetrics( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] +): { + eliminationsPer10StdDev: number; + deathsPer10StdDev: number; + allDamagePer10StdDev: number; + healingDealtPer10StdDev: number; + firstPickPercentageStdDev: number; + consistencyScore: number; +} { + if (perMapStats.length < 2) { + return { + eliminationsPer10StdDev: 0, + deathsPer10StdDev: 0, + allDamagePer10StdDev: 0, + healingDealtPer10StdDev: 0, + firstPickPercentageStdDev: 0, + consistencyScore: 0, + }; + } + + const eliminationsPer10Values = perMapStats.map((stat) => + calculatePer10(stat.eliminations, stat.hero_time_played) + ); + const deathsPer10Values = perMapStats.map((stat) => + calculatePer10(stat.deaths, stat.hero_time_played) + ); + const allDamagePer10Values = perMapStats.map((stat) => + calculatePer10(stat.all_damage_dealt, stat.hero_time_played) + ); + const healingDealtPer10Values = perMapStats.map((stat) => + calculatePer10(stat.healing_dealt, stat.hero_time_played) + ); + + const firstPickPercentageValues = perMapCalculatedStats + .map((stats) => { + const firstPickStat = stats.find( + (s) => s.stat === CalculatedStatType.FIRST_PICK_PERCENTAGE + ); + return firstPickStat?.value ?? 0; + }) + .filter((v) => v > 0); + + const eliminationsPer10Mean = calculateMean(eliminationsPer10Values); + const deathsPer10Mean = calculateMean(deathsPer10Values); + const allDamagePer10Mean = calculateMean(allDamagePer10Values); + const healingDealtPer10Mean = calculateMean(healingDealtPer10Values); + const firstPickPercentageMean = calculateMean(firstPickPercentageValues); + + const eliminationsPer10StdDev = calculateStandardDeviation( + eliminationsPer10Values, + eliminationsPer10Mean + ); + const deathsPer10StdDev = calculateStandardDeviation( + deathsPer10Values, + deathsPer10Mean + ); + const allDamagePer10StdDev = calculateStandardDeviation( + allDamagePer10Values, + allDamagePer10Mean + ); + const healingDealtPer10StdDev = calculateStandardDeviation( + healingDealtPer10Values, + healingDealtPer10Mean + ); + const firstPickPercentageStdDev = + firstPickPercentageValues.length > 1 + ? calculateStandardDeviation( + firstPickPercentageValues, + firstPickPercentageMean + ) + : 0; + + const coefficientOfVariations = [ + eliminationsPer10Mean > 0 + ? eliminationsPer10StdDev / eliminationsPer10Mean + : 0, + deathsPer10Mean > 0 ? deathsPer10StdDev / deathsPer10Mean : 0, + allDamagePer10Mean > 0 ? allDamagePer10StdDev / allDamagePer10Mean : 0, + healingDealtPer10Mean > 0 + ? healingDealtPer10StdDev / healingDealtPer10Mean + : 0, + ].filter((cv) => cv > 0); + + const averageCV = + coefficientOfVariations.length > 0 + ? calculateMean(coefficientOfVariations) + : 0; + + const consistencyScore = + averageCV > 0 ? Math.max(0, Math.min(100, (1 - averageCV) * 100)) : 0; + + return { + eliminationsPer10StdDev, + deathsPer10StdDev, + allDamagePer10StdDev, + healingDealtPer10StdDev, + firstPickPercentageStdDev, + consistencyScore, + }; +} + +export function aggregatePlayerStats( + stats: PlayerStat[], + calculatedStats: CalculatedStat[], + perMapStats?: PlayerStat[], + perMapCalculatedStats?: CalculatedStat[][] +): AggregatedStats { + const totals = stats.reduce( + (acc, stat) => { + acc.eliminations += stat.eliminations; + acc.finalBlows += stat.final_blows; + acc.deaths += stat.deaths; + acc.allDamageDealt += stat.all_damage_dealt; + acc.barrierDamageDealt += stat.barrier_damage_dealt; + acc.heroDamageDealt += stat.hero_damage_dealt; + acc.healingDealt += stat.healing_dealt; + acc.healingReceived += stat.healing_received; + acc.selfHealing += stat.self_healing; + acc.damageTaken += stat.damage_taken; + acc.damageBlocked += stat.damage_blocked; + acc.defensiveAssists += stat.defensive_assists; + acc.offensiveAssists += stat.offensive_assists; + acc.ultimatesEarned += stat.ultimates_earned; + acc.ultimatesUsed += stat.ultimates_used; + acc.multikillBest = Math.max(acc.multikillBest, stat.multikill_best); + acc.multikills += stat.multikills; + acc.soloKills += stat.solo_kills; + acc.objectiveKills += stat.objective_kills; + acc.environmentalKills += stat.environmental_kills; + acc.environmentalDeaths += stat.environmental_deaths; + acc.criticalHits += stat.critical_hits; + acc.shotsFired += stat.shots_fired; + acc.shotsHit += stat.shots_hit; + acc.shotsMissed += stat.shots_missed; + acc.scopedShots += stat.scoped_shots; + acc.scopedShotsHit += stat.scoped_shots_hit; + acc.scopedCriticalHitKills += stat.scoped_critical_hit_kills; + acc.heroTimePlayed += stat.hero_time_played; + return acc; + }, + { + eliminations: 0, + finalBlows: 0, + deaths: 0, + allDamageDealt: 0, + barrierDamageDealt: 0, + heroDamageDealt: 0, + healingDealt: 0, + healingReceived: 0, + selfHealing: 0, + damageTaken: 0, + damageBlocked: 0, + defensiveAssists: 0, + offensiveAssists: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + multikillBest: 0, + multikills: 0, + soloKills: 0, + objectiveKills: 0, + environmentalKills: 0, + environmentalDeaths: 0, + criticalHits: 0, + shotsFired: 0, + shotsHit: 0, + shotsMissed: 0, + scopedShots: 0, + scopedShotsHit: 0, + scopedCriticalHitKills: 0, + heroTimePlayed: 0, + } + ); + + const calculatedAggregates = aggregateCalculatedStats(calculatedStats); + + const varianceMetrics = + perMapStats && perMapCalculatedStats + ? calculateVarianceMetrics(perMapStats, perMapCalculatedStats) + : { + eliminationsPer10StdDev: 0, + deathsPer10StdDev: 0, + allDamagePer10StdDev: 0, + healingDealtPer10StdDev: 0, + firstPickPercentageStdDev: 0, + consistencyScore: 0, + }; + + return { + ...totals, + eliminationsPer10: calculatePer10( + totals.eliminations, + totals.heroTimePlayed + ), + finalBlowsPer10: calculatePer10(totals.finalBlows, totals.heroTimePlayed), + deathsPer10: calculatePer10(totals.deaths, totals.heroTimePlayed), + allDamagePer10: calculatePer10( + totals.allDamageDealt, + totals.heroTimePlayed + ), + heroDamagePer10: calculatePer10( + totals.heroDamageDealt, + totals.heroTimePlayed + ), + healingDealtPer10: calculatePer10( + totals.healingDealt, + totals.heroTimePlayed + ), + healingReceivedPer10: calculatePer10( + totals.healingReceived, + totals.heroTimePlayed + ), + damageTakenPer10: calculatePer10(totals.damageTaken, totals.heroTimePlayed), + damageBlockedPer10: calculatePer10( + totals.damageBlocked, + totals.heroTimePlayed + ), + ultimatesEarnedPer10: calculatePer10( + totals.ultimatesEarned, + totals.heroTimePlayed + ), + ultimatesUsedPer10: calculatePer10( + totals.ultimatesUsed, + totals.heroTimePlayed + ), + soloKillsPer10: calculatePer10(totals.soloKills, totals.heroTimePlayed), + objectiveKillsPer10: calculatePer10( + totals.objectiveKills, + totals.heroTimePlayed + ), + defensiveAssistsPer10: calculatePer10( + totals.defensiveAssists, + totals.heroTimePlayed + ), + offensiveAssistsPer10: calculatePer10( + totals.offensiveAssists, + totals.heroTimePlayed + ), + environmentalKillsPer10: calculatePer10( + totals.environmentalKills, + totals.heroTimePlayed + ), + environmentalDeathsPer10: calculatePer10( + totals.environmentalDeaths, + totals.heroTimePlayed + ), + multikillsPer10: calculatePer10(totals.multikills, totals.heroTimePlayed), + barrierDamagePer10: calculatePer10( + totals.barrierDamageDealt, + totals.heroTimePlayed + ), + selfHealingPer10: calculatePer10(totals.selfHealing, totals.heroTimePlayed), + firstPicksPer10: calculatePer10( + calculatedAggregates.firstPickCount ?? 0, + totals.heroTimePlayed + ), + firstDeathsPer10: calculatePer10( + calculatedAggregates.firstDeathCount ?? 0, + totals.heroTimePlayed + ), + mapMvpRate: + perMapCalculatedStats && perMapCalculatedStats.length > 0 + ? ((calculatedAggregates.mapMvpCount ?? 0) / + perMapCalculatedStats.length) * + 100 + : stats.length > 0 + ? ((calculatedAggregates.mapMvpCount ?? 0) / stats.length) * 100 + : 0, + ajaxPer10: calculatePer10( + calculatedAggregates.ajaxCount ?? 0, + totals.heroTimePlayed + ), + weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired), + criticalHitAccuracy: calculatePercentage( + totals.criticalHits, + totals.shotsHit + ), + scopedAccuracy: calculatePercentage( + totals.scopedShotsHit, + totals.scopedShots + ), + scopedCriticalHitAccuracy: calculatePercentage( + totals.scopedCriticalHitKills, + totals.scopedShotsHit + ), + fletaDeadliftPercentage: calculatedAggregates.fletaDeadliftPercentage ?? 0, + firstPickPercentage: calculatedAggregates.firstPickPercentage ?? 0, + firstPickCount: calculatedAggregates.firstPickCount ?? 0, + firstDeathPercentage: calculatedAggregates.firstDeathPercentage ?? 0, + firstDeathCount: calculatedAggregates.firstDeathCount ?? 0, + mvpScore: calculatedAggregates.mvpScore ?? 0, + mapMvpCount: calculatedAggregates.mapMvpCount ?? 0, + ajaxCount: calculatedAggregates.ajaxCount ?? 0, + averageUltChargeTime: calculatedAggregates.averageUltChargeTime ?? 0, + averageTimeToUseUlt: calculatedAggregates.averageTimeToUseUlt ?? 0, + averageDroughtTime: calculatedAggregates.averageDroughtTime ?? 0, + killsPerUltimate: calculatedAggregates.killsPerUltimate ?? 0, + duelWinratePercentage: calculatedAggregates.duelWinratePercentage ?? 0, + fightReversalPercentage: calculatedAggregates.fightReversalPercentage ?? 0, + ...varianceMetrics, + }; +} + +export function calculateTrends( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] +): TrendsAnalysis { + if (perMapStats.length < 3) { + return { + improvingMetrics: [], + decliningMetrics: [], + }; + } + + const midpoint = Math.floor(perMapStats.length / 2); + const firstHalfStats = perMapStats.slice(0, midpoint); + const secondHalfStats = perMapStats.slice(midpoint); + + const firstHalfCalculated = perMapCalculatedStats.slice(0, midpoint).flat(); + const secondHalfCalculated = perMapCalculatedStats.slice(midpoint).flat(); + + const earlyPerformance = aggregatePlayerStats( + firstHalfStats, + firstHalfCalculated + ); + const latePerformance = aggregatePlayerStats( + secondHalfStats, + secondHalfCalculated + ); + + const metricComparisons = [ + { + name: "Eliminations per 10", + early: earlyPerformance.eliminationsPer10, + late: latePerformance.eliminationsPer10, + }, + { + name: "Deaths per 10", + early: earlyPerformance.deathsPer10, + late: latePerformance.deathsPer10, + invertImprovement: true, + }, + { + name: "Damage Dealt per 10", + early: earlyPerformance.heroDamagePer10, + late: latePerformance.heroDamagePer10, + }, + { + name: "Damage Taken per 10", + early: earlyPerformance.damageTakenPer10, + late: latePerformance.damageTakenPer10, + invertImprovement: true, + }, + { + name: "First Death %", + early: earlyPerformance.firstDeathPercentage, + late: latePerformance.firstDeathPercentage, + invertImprovement: true, + }, + { + name: "First Pick %", + early: earlyPerformance.firstPickPercentage, + late: latePerformance.firstPickPercentage, + }, + { + name: "MVP Score", + early: earlyPerformance.mvpScore, + late: latePerformance.mvpScore, + }, + { + name: "Fight Reversal %", + early: earlyPerformance.fightReversalPercentage, + late: latePerformance.fightReversalPercentage, + }, + { + name: "Fleta Deadlift %", + early: earlyPerformance.fletaDeadliftPercentage, + late: latePerformance.fletaDeadliftPercentage, + }, + { + name: "Kills per Ultimate", + early: earlyPerformance.killsPerUltimate, + late: latePerformance.killsPerUltimate, + }, + ]; + + const improvingMetrics: { + metric: string; + change: number; + changePercentage: number; + }[] = []; + const decliningMetrics: { + metric: string; + change: number; + changePercentage: number; + }[] = []; + + metricComparisons.forEach((comparison) => { + const change = comparison.late - comparison.early; + const changePercentage = + comparison.early !== 0 + ? ((comparison.late - comparison.early) / comparison.early) * 100 + : 0; + + const isImprovement = comparison.invertImprovement + ? change < 0 + : change > 0; + + if (Math.abs(changePercentage) > 5) { + if (isImprovement) { + improvingMetrics.push({ + metric: comparison.name, + change, + changePercentage, + }); + } else { + decliningMetrics.push({ + metric: comparison.name, + change, + changePercentage, + }); + } + } + }); + + return { + improvingMetrics, + decliningMetrics, + earlyPerformance: perMapStats.length >= 4 ? earlyPerformance : undefined, + latePerformance: perMapStats.length >= 4 ? latePerformance : undefined, + }; +} diff --git a/src/data/comparison/errors.ts b/src/data/comparison/errors.ts new file mode 100644 index 000000000..46566d235 --- /dev/null +++ b/src/data/comparison/errors.ts @@ -0,0 +1,25 @@ +import { Schema as S } from "effect"; + +export class ComparisonQueryError extends S.TaggedError()( + "ComparisonQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Comparison query failed: ${this.operation}`; + } +} + +export class ComparisonValidationError extends S.TaggedError()( + "ComparisonValidationError", + { + field: S.String, + reason: S.String, + } +) { + get message(): string { + return `Comparison validation failed on ${this.field}: ${this.reason}`; + } +} diff --git a/src/data/comparison/index.ts b/src/data/comparison/index.ts new file mode 100644 index 000000000..af6b88f22 --- /dev/null +++ b/src/data/comparison/index.ts @@ -0,0 +1,30 @@ +export { + ComparisonAggregationService, + ComparisonAggregationServiceLive, +} from "./aggregation-service"; +export type { ComparisonAggregationServiceInterface } from "./aggregation-service"; + +export { + ComparisonTrendsService, + ComparisonTrendsServiceLive, +} from "./trends-service"; +export type { ComparisonTrendsServiceInterface } from "./trends-service"; + +export { + aggregatePlayerStats, + calculateTrends, + calculatePer10, + calculatePercentage, +} from "./computation"; + +export { ComparisonQueryError, ComparisonValidationError } from "./errors"; + +export type { + AggregatedStats, + AvailableMap, + ComparisonStats, + GetAvailableMapsParams, + MapBreakdown, + TeamPlayer, + TrendsAnalysis, +} from "./types"; diff --git a/src/data/comparison/metrics.ts b/src/data/comparison/metrics.ts new file mode 100644 index 000000000..4fdd27832 --- /dev/null +++ b/src/data/comparison/metrics.ts @@ -0,0 +1,99 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const comparisonStatsSuccessTotal = Metric.counter( + "comparison.stats.query.success", + { + description: "Total successful comparison stats queries", + incremental: true, + } +); + +export const comparisonStatsErrorTotal = Metric.counter( + "comparison.stats.query.error", + { + description: "Total comparison stats query failures", + incremental: true, + } +); + +export const comparisonStatsDuration = Metric.histogram( + "comparison.stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of comparison stats query duration in milliseconds" +); + +export const availableMapsSuccessTotal = Metric.counter( + "comparison.available_maps.query.success", + { + description: "Total successful available maps queries", + incremental: true, + } +); + +export const availableMapsErrorTotal = Metric.counter( + "comparison.available_maps.query.error", + { + description: "Total available maps query failures", + incremental: true, + } +); + +export const availableMapsDuration = Metric.histogram( + "comparison.available_maps.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of available maps query duration in milliseconds" +); + +export const teamPlayersSuccessTotal = Metric.counter( + "comparison.team_players.query.success", + { + description: "Total successful team players queries", + incremental: true, + } +); + +export const teamPlayersErrorTotal = Metric.counter( + "comparison.team_players.query.error", + { + description: "Total team players query failures", + incremental: true, + } +); + +export const teamPlayersDuration = Metric.histogram( + "comparison.team_players.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team players query duration in milliseconds" +); + +export const trendsSuccessTotal = Metric.counter("comparison.trends.success", { + description: "Total successful trends calculations", + incremental: true, +}); + +export const trendsErrorTotal = Metric.counter("comparison.trends.error", { + description: "Total trends calculation failures", + incremental: true, +}); + +export const trendsDuration = Metric.histogram( + "comparison.trends.duration_ms", + MetricBoundaries.exponential({ start: 1, factor: 2, count: 10 }), + "Distribution of trends calculation duration in milliseconds" +); + +export const comparisonCacheRequestTotal = Metric.counter( + "comparison.cache.request", + { + description: "Total comparison data cache requests", + incremental: true, + } +); + +export const comparisonCacheMissTotal = Metric.counter( + "comparison.cache.miss", + { + description: "Total comparison data cache misses (triggered lookup)", + incremental: true, + } +); diff --git a/src/data/comparison/trends-service.ts b/src/data/comparison/trends-service.ts new file mode 100644 index 000000000..07e5d6864 --- /dev/null +++ b/src/data/comparison/trends-service.ts @@ -0,0 +1,115 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import type { CalculatedStat, PlayerStat } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { calculateTrends } from "./computation"; +import { + comparisonCacheRequestTotal, + comparisonCacheMissTotal, + trendsDuration, + trendsErrorTotal, + trendsSuccessTotal, +} from "./metrics"; +import type { TrendsAnalysis } from "./types"; + +export { calculateTrends } from "./computation"; + +export type ComparisonTrendsServiceInterface = { + readonly calculateTrends: ( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] + ) => Effect.Effect; +}; + +export class ComparisonTrendsService extends Context.Tag( + "@app/data/comparison/ComparisonTrendsService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function calculateTrendsEffect( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + perMapStatCount: perMapStats.length, + perMapCalculatedStatGroupCount: perMapCalculatedStats.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan( + "perMapStatCount", + perMapStats.length + ); + const result = calculateTrends(perMapStats, perMapCalculatedStats); + + wideEvent.improvingCount = result.improvingMetrics.length; + wideEvent.decliningCount = result.decliningMetrics.length; + wideEvent.hasEarlyPerformance = !!result.earlyPerformance; + wideEvent.hasLatePerformance = !!result.latePerformance; + wideEvent.outcome = "success"; + yield* Metric.increment(trendsSuccessTotal); + + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = (error as { _tag?: string })._tag; + wideEvent.error_message = String(error); + }).pipe(Effect.andThen(Metric.increment(trendsErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("comparison.calculateTrends") + : Effect.logInfo("comparison.calculateTrends"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(trendsDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("comparison.calculateTrends") + ); + } + + const trendsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [perMapStats, perMapCalculatedStats] = JSON.parse(key) as [ + PlayerStat[], + CalculatedStat[][], + ]; + return calculateTrendsEffect(perMapStats, perMapCalculatedStats).pipe( + Effect.tap(() => Metric.increment(comparisonCacheMissTotal)) + ); + }, + }); + + return { + calculateTrends: ( + perMapStats: PlayerStat[], + perMapCalculatedStats: CalculatedStat[][] + ) => + trendsCache + .get(JSON.stringify([perMapStats, perMapCalculatedStats])) + .pipe( + Effect.tap(() => Metric.increment(comparisonCacheRequestTotal)) + ), + } satisfies ComparisonTrendsServiceInterface; + } +); + +export const ComparisonTrendsServiceLive = Layer.effect( + ComparisonTrendsService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/comparison/types.ts b/src/data/comparison/types.ts new file mode 100644 index 000000000..572490b6e --- /dev/null +++ b/src/data/comparison/types.ts @@ -0,0 +1,153 @@ +import type { HeroName } from "@/types/heroes"; +import type { CalculatedStat, MapType, PlayerStat } from "@prisma/client"; + +export type AggregatedStats = { + eliminations: number; + finalBlows: number; + deaths: number; + allDamageDealt: number; + barrierDamageDealt: number; + heroDamageDealt: number; + healingDealt: number; + healingReceived: number; + selfHealing: number; + damageTaken: number; + damageBlocked: number; + defensiveAssists: number; + offensiveAssists: number; + ultimatesEarned: number; + ultimatesUsed: number; + multikillBest: number; + multikills: number; + soloKills: number; + objectiveKills: number; + environmentalKills: number; + environmentalDeaths: number; + criticalHits: number; + shotsFired: number; + shotsHit: number; + shotsMissed: number; + scopedShots: number; + scopedShotsHit: number; + scopedCriticalHitKills: number; + heroTimePlayed: number; + eliminationsPer10: number; + finalBlowsPer10: number; + deathsPer10: number; + allDamagePer10: number; + heroDamagePer10: number; + healingDealtPer10: number; + healingReceivedPer10: number; + damageTakenPer10: number; + damageBlockedPer10: number; + ultimatesEarnedPer10: number; + ultimatesUsedPer10: number; + soloKillsPer10: number; + objectiveKillsPer10: number; + defensiveAssistsPer10: number; + offensiveAssistsPer10: number; + environmentalKillsPer10: number; + environmentalDeathsPer10: number; + multikillsPer10: number; + barrierDamagePer10: number; + selfHealingPer10: number; + firstPicksPer10: number; + firstDeathsPer10: number; + mapMvpRate: number; + ajaxPer10: number; + weaponAccuracy: number; + criticalHitAccuracy: number; + scopedAccuracy: number; + scopedCriticalHitAccuracy: number; + fletaDeadliftPercentage: number; + firstPickPercentage: number; + firstPickCount: number; + firstDeathPercentage: number; + firstDeathCount: number; + mvpScore: number; + mapMvpCount: number; + ajaxCount: number; + averageUltChargeTime: number; + averageTimeToUseUlt: number; + averageDroughtTime: number; + killsPerUltimate: number; + duelWinratePercentage: number; + fightReversalPercentage: number; + eliminationsPer10StdDev: number; + deathsPer10StdDev: number; + allDamagePer10StdDev: number; + healingDealtPer10StdDev: number; + firstPickPercentageStdDev: number; + consistencyScore: number; +}; + +export type MapBreakdown = { + mapId: number; + mapDataId: number; + mapName: string; + mapType: MapType; + scrimId: number; + scrimName: string; + date: Date; + replayCode: string | null; + heroes: HeroName[]; + stats: PlayerStat & { + eliminationsPer10?: number; + deathsPer10?: number; + allDamagePer10?: number; + healingDealtPer10?: number; + damageBlockedPer10?: number; + }; + calculatedStats: CalculatedStat[]; +}; + +export type TrendsAnalysis = { + improvingMetrics: { + metric: string; + change: number; + changePercentage: number; + }[]; + decliningMetrics: { + metric: string; + change: number; + changePercentage: number; + }[]; + earlyPerformance?: AggregatedStats; + latePerformance?: AggregatedStats; +}; + +export type ComparisonStats = { + playerName: string; + filteredHeroes: HeroName[]; + mapCount: number; + mapIds: number[]; + aggregated: AggregatedStats; + perMapBreakdown: MapBreakdown[]; + trends?: TrendsAnalysis; + heroBreakdown?: Record; +}; + +export type AvailableMap = { + id: number; + name: string; + scrimId: number; + scrimName: string; + date: Date; + mapType: MapType; + replayCode: string | null; + playerHeroes: HeroName[]; +}; + +export type TeamPlayer = { + name: string; + mapCount: number; +}; + +export type GetAvailableMapsParams = { + teamId: number; + playerName: string; + dateFrom?: Date; + dateTo?: Date; + mapType?: MapType; + heroes?: HeroName[]; +}; From 2b66a557a74279ee56b777b1a36f8193a6818828 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:04 -0400 Subject: [PATCH 09/42] Add scouting and intelligence Effect services Scouting covers opponent match data and Elo-like strength ratings. Intelligence covers hero ban analysis and map strategy, consuming scouting and team services via Effect Context dependencies. --- src/data/intelligence/errors.ts | 25 + src/data/intelligence/hero-ban-service.ts | 547 +++++++++++++++++ src/data/intelligence/index.ts | 32 + src/data/intelligence/map-service.ts | 536 +++++++++++++++++ src/data/intelligence/metrics.ts | 67 +++ src/data/intelligence/types.ts | 155 +++++ src/data/scouting/errors.ts | 24 + src/data/scouting/index.ts | 28 + src/data/scouting/metrics.ts | 148 +++++ .../scouting/opponent-strength-service.ts | 355 +++++++++++ src/data/scouting/scouting-service.ts | 556 ++++++++++++++++++ src/data/scouting/types.ts | 83 +++ 12 files changed, 2556 insertions(+) create mode 100644 src/data/intelligence/errors.ts create mode 100644 src/data/intelligence/hero-ban-service.ts create mode 100644 src/data/intelligence/index.ts create mode 100644 src/data/intelligence/map-service.ts create mode 100644 src/data/intelligence/metrics.ts create mode 100644 src/data/intelligence/types.ts create mode 100644 src/data/scouting/errors.ts create mode 100644 src/data/scouting/index.ts create mode 100644 src/data/scouting/metrics.ts create mode 100644 src/data/scouting/opponent-strength-service.ts create mode 100644 src/data/scouting/scouting-service.ts create mode 100644 src/data/scouting/types.ts diff --git a/src/data/intelligence/errors.ts b/src/data/intelligence/errors.ts new file mode 100644 index 000000000..4c4bc01f3 --- /dev/null +++ b/src/data/intelligence/errors.ts @@ -0,0 +1,25 @@ +import { Schema as S } from "effect"; + +export class HeroBanIntelligenceQueryError extends S.TaggedError()( + "HeroBanIntelligenceQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Hero ban intelligence query failed: ${this.operation}`; + } +} + +export class MapIntelligenceQueryError extends S.TaggedError()( + "MapIntelligenceQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Map intelligence query failed: ${this.operation}`; + } +} diff --git a/src/data/intelligence/hero-ban-service.ts b/src/data/intelligence/hero-ban-service.ts new file mode 100644 index 000000000..1ee9da372 --- /dev/null +++ b/src/data/intelligence/hero-ban-service.ts @@ -0,0 +1,547 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { assessConfidence } from "@/lib/confidence"; +import { + hasScrimData, + SCRIM_CONFIDENCE_THRESHOLDS, + type DataAvailabilityProfile, +} from "@/lib/data-availability"; +import { + ScoutingService, + ScoutingServiceLive, + type OpponentMatchRow, +} from "@/data/scouting/scouting-service"; +import { + ScrimOpponentService, + ScrimOpponentServiceLive, +} from "@/data/scrim/opponent-service"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; +import type { MapType } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { HeroBanIntelligenceQueryError } from "./errors"; +import { + heroBanIntelligenceQueryDuration, + heroBanIntelligenceQueryErrorTotal, + heroBanIntelligenceQuerySuccessTotal, + intelligenceCacheRequestTotal, + intelligenceCacheMissTotal, +} from "./metrics"; +import type { + BanDisruptionEntry, + BanRateByMapType, + ComfortCrutch, + HeroBanIntelligence, + HeroExposure, + HeroWinRateDelta, + MapWithBans, + ProtectedHero, +} from "./types"; + +function getOpponentMapBanData( + matches: OpponentMatchRow[], + teamAbbr: string +): MapWithBans[] { + const results: MapWithBans[] = []; + for (const match of matches) { + const teamSide = + match.team1 === teamAbbr ? ("team1" as const) : ("team2" as const); + for (const map of match.maps) { + results.push({ + mapName: map.mapName, + mapType: map.mapType, + matchDate: match.matchDate, + teamSide, + won: map.winner === teamSide, + heroBans: map.heroBans.map((b) => ({ team: b.team, hero: b.hero })), + source: "owcs", + }); + } + } + return results; +} + +function computeWinRateDeltas( + maps: MapWithBans[], + includesScrimData: boolean +): HeroWinRateDelta[] { + const confidenceThresholds = includesScrimData + ? SCRIM_CONFIDENCE_THRESHOLDS + : undefined; + + const totalMaps = maps.length; + const totalWins = maps.filter((m) => m.won).length; + + const bannedAccum = new Map< + string, + { bannedTotal: number; bannedWins: number } + >(); + + for (const map of maps) { + for (const ban of map.heroBans) { + let accum = bannedAccum.get(ban.hero); + if (!accum) { + accum = { bannedTotal: 0, bannedWins: 0 }; + bannedAccum.set(ban.hero, accum); + } + accum.bannedTotal++; + if (map.won) accum.bannedWins++; + } + } + + return Array.from(bannedAccum.entries()) + .map(([hero, accum]) => { + const availableTotal = totalMaps - accum.bannedTotal; + const availableWins = totalWins - accum.bannedWins; + + const winRateWhenAvailable = + availableTotal > 0 ? (availableWins / availableTotal) * 100 : 0; + const winRateWhenBanned = + accum.bannedTotal > 0 + ? (accum.bannedWins / accum.bannedTotal) * 100 + : 0; + + return { + hero, + winRateWhenAvailable, + winRateWhenBanned, + delta: winRateWhenAvailable - winRateWhenBanned, + mapsAvailable: availableTotal, + mapsBanned: accum.bannedTotal, + confidenceAvailable: assessConfidence( + availableTotal, + confidenceThresholds + ), + confidenceBanned: assessConfidence( + accum.bannedTotal, + confidenceThresholds + ), + }; + }) + .sort((a, b) => b.delta - a.delta); +} + +function computeComfortCrutches( + maps: MapWithBans[], + winRateDeltas: HeroWinRateDelta[], + includesScrimData: boolean +): ComfortCrutch[] { + const confidenceThresholds = includesScrimData + ? SCRIM_CONFIDENCE_THRESHOLDS + : undefined; + const totalMaps = maps.length; + + const banCounts = new Map(); + for (const map of maps) { + for (const ban of map.heroBans) { + banCounts.set(ban.hero, (banCounts.get(ban.hero) ?? 0) + 1); + } + } + + const deltaMap = new Map(winRateDeltas.map((d) => [d.hero, d])); + + return Array.from(banCounts.entries()) + .map(([hero, banFrequency]) => { + const banRate = totalMaps > 0 ? (banFrequency / totalMaps) * 100 : 0; + const wrDelta = deltaMap.get(hero); + const winRateDelta = wrDelta?.delta ?? 0; + + // Crutch score = ban rate * WR delta (higher = more exploitable) + // Normalized: ban rate as fraction * delta as fraction + const crutchScore = (banRate / 100) * Math.max(0, winRateDelta); + + return { + hero, + banFrequency, + totalMaps, + banRate, + winRateDelta, + crutchScore, + confidence: assessConfidence(banFrequency, confidenceThresholds), + }; + }) + .filter((c) => c.winRateDelta > 0) + .sort((a, b) => b.crutchScore - a.crutchScore); +} + +function computeProtectedHeroes(maps: MapWithBans[]): ProtectedHero[] { + const totalMaps = maps.length; + const bansByTeam = new Map(); + + for (const map of maps) { + for (const ban of map.heroBans) { + if (ban.team === map.teamSide) { + bansByTeam.set(ban.hero, (bansByTeam.get(ban.hero) ?? 0) + 1); + } + } + } + + return Array.from(bansByTeam.entries()) + .map(([hero, timesBannedByTeam]) => ({ + hero, + timesBannedByTeam, + totalMaps, + banRate: totalMaps > 0 ? (timesBannedByTeam / totalMaps) * 100 : 0, + })) + .sort((a, b) => a.banRate - b.banRate) + .filter((h) => h.banRate < 5); +} + +function computeBanRateByMapType(maps: MapWithBans[]): BanRateByMapType[] { + const mapTypeCount = new Map(); + const heroBansByType = new Map>(); + + for (const map of maps) { + mapTypeCount.set(map.mapType, (mapTypeCount.get(map.mapType) ?? 0) + 1); + + for (const ban of map.heroBans) { + let heroTypes = heroBansByType.get(ban.hero); + if (!heroTypes) { + heroTypes = new Map(); + heroBansByType.set(ban.hero, heroTypes); + } + heroTypes.set(map.mapType, (heroTypes.get(map.mapType) ?? 0) + 1); + } + } + + const results: BanRateByMapType[] = []; + for (const [hero, typeMap] of heroBansByType) { + for (const [mapType, banCount] of typeMap) { + const totalMapsOfType = mapTypeCount.get(mapType) ?? 0; + results.push({ + hero, + mapType, + banCount, + totalMapsOfType, + banRate: totalMapsOfType > 0 ? (banCount / totalMapsOfType) * 100 : 0, + }); + } + } + + return results.sort((a, b) => b.banRate - a.banRate); +} + +function computeHeroExposureSync( + maps: MapWithBans[], + allPlayerStats: { + player_name: string; + player_hero: string; + hero_time_played: number; + }[], + teamRosterSet: Set +): HeroExposure[] { + const heroTimePlayed = new Map(); + let totalTimePlayed = 0; + + for (const stat of allPlayerStats) { + if (!teamRosterSet.has(stat.player_name)) continue; + const current = heroTimePlayed.get(stat.player_hero) ?? 0; + heroTimePlayed.set(stat.player_hero, current + stat.hero_time_played); + totalTimePlayed += stat.hero_time_played; + } + + const totalOpponentMaps = maps.length; + const opponentBanCounts = new Map(); + for (const map of maps) { + const opponentSide = map.teamSide === "team1" ? "team2" : "team1"; + for (const ban of map.heroBans) { + if (ban.team === opponentSide) { + opponentBanCounts.set( + ban.hero, + (opponentBanCounts.get(ban.hero) ?? 0) + 1 + ); + } + } + } + + const exposures: HeroExposure[] = []; + for (const [hero, timePlayed] of heroTimePlayed) { + const playRate = + totalTimePlayed > 0 ? (timePlayed / totalTimePlayed) * 100 : 0; + if (playRate < 1) continue; + + const opponentBanCount = opponentBanCounts.get(hero) ?? 0; + const opponentBanRate = + totalOpponentMaps > 0 ? (opponentBanCount / totalOpponentMaps) * 100 : 0; + + const HIGH_BAN_THRESHOLD = 30; + const MEDIUM_BAN_THRESHOLD = 15; + const exposureRisk: HeroExposure["exposureRisk"] = + opponentBanRate >= HIGH_BAN_THRESHOLD && playRate >= 10 + ? "high" + : opponentBanRate >= MEDIUM_BAN_THRESHOLD && playRate >= 5 + ? "medium" + : "low"; + + exposures.push({ + hero, + userPlayRate: playRate, + userTimePlayed: timePlayed, + opponentBanRate, + opponentBanCount, + exposureRisk, + }); + } + + return exposures + .filter((e) => e.opponentBanCount > 0) + .sort((a, b) => { + const riskOrder = { high: 0, medium: 1, low: 2 }; + if (riskOrder[a.exposureRisk] !== riskOrder[b.exposureRisk]) { + return riskOrder[a.exposureRisk] - riskOrder[b.exposureRisk]; + } + return b.userPlayRate - a.userPlayRate; + }); +} + +function computeBanDisruptionRanking( + winRateDeltas: HeroWinRateDelta[], + includesScrimData: boolean +): BanDisruptionEntry[] { + const confidenceThresholds = includesScrimData + ? SCRIM_CONFIDENCE_THRESHOLDS + : undefined; + + return winRateDeltas + .filter((d) => d.mapsBanned >= 2) + .map((d) => { + // Disruption score weights the WR delta by the confidence of the ban sample. + // A large delta from 2 maps is less reliable than a moderate delta from 10. + const sampleWeight = Math.min(d.mapsBanned / 10, 1); + const disruptionScore = d.delta * sampleWeight; + + return { + hero: d.hero, + winRateDelta: d.delta, + mapsAvailable: d.mapsAvailable, + mapsBanned: d.mapsBanned, + disruptionScore, + confidence: assessConfidence(d.mapsBanned, confidenceThresholds), + }; + }) + .sort((a, b) => b.disruptionScore - a.disruptionScore); +} + +export type HeroBanIntelligenceServiceInterface = { + readonly getHeroBanIntelligence: ( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) => Effect.Effect; +}; + +// Context tag + +export class HeroBanIntelligenceService extends Context.Tag( + "@app/data/intelligence/HeroBanIntelligenceService" +)() {} + +// Implementation + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make = Effect.gen(function* () { + const scouting = yield* ScoutingService; + const scrimOpponent = yield* ScrimOpponentService; + const teamSharedData = yield* TeamSharedDataService; + function getHeroBanIntelligence( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + opponentAbbr, + userTeamId, + hasProfile: !!profile, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("opponentAbbr", opponentAbbr); + yield* Effect.annotateCurrentSpan("userTeamId", String(userTeamId)); + const matches = yield* scouting.getOpponentMatchData(opponentAbbr).pipe( + Effect.mapError( + (error) => + new HeroBanIntelligenceQueryError({ + operation: "fetch opponent match data", + cause: error, + }) + ), + Effect.withSpan("intelligence.heroBan.fetchOpponentMatchData", { + attributes: { opponentAbbr }, + }) + ); + + const owcsMaps = getOpponentMapBanData(matches, opponentAbbr); + + const includesScrimData = + !!profile && !!userTeamId && hasScrimData(profile); + wideEvent.includesScrimData = includesScrimData; + + let maps: MapWithBans[] = owcsMaps; + + if (includesScrimData && userTeamId !== null) { + const scrimBans = yield* scrimOpponent + .getOpponentScrimHeroBans(userTeamId, opponentAbbr) + .pipe( + Effect.mapError( + (error) => + new HeroBanIntelligenceQueryError({ + operation: "fetch opponent scrim hero bans", + cause: error, + }) + ), + Effect.withSpan("intelligence.heroBan.fetchScrimHeroBans", { + attributes: { opponentAbbr, userTeamId }, + }) + ); + + const scrimMaps: MapWithBans[] = scrimBans.map((s) => ({ + mapName: s.mapName, + mapType: s.mapType, + matchDate: s.scrimDate, + teamSide: "team2" as const, + won: !s.opponentWon, + heroBans: s.opponentBans.map((hero) => ({ + team: "team2", + hero, + })), + source: "scrim" as const, + })); + maps = [...owcsMaps, ...scrimMaps]; + } + + wideEvent.mapCount = maps.length; + wideEvent.owcsMapCount = owcsMaps.length; + wideEvent.scrimMapCount = maps.length - owcsMaps.length; + + const winRateDeltas = computeWinRateDeltas(maps, includesScrimData); + const comfortCrutches = computeComfortCrutches( + maps, + winRateDeltas, + includesScrimData + ); + const protectedHeroes = computeProtectedHeroes(maps); + const banRateByMapType = computeBanRateByMapType(maps); + const banDisruptionRanking = computeBanDisruptionRanking( + winRateDeltas, + includesScrimData + ); + + let heroExposure: HeroExposure[] = []; + if (userTeamId !== null) { + const baseData = yield* teamSharedData.getBaseTeamData(userTeamId).pipe( + Effect.mapError( + (error) => + new HeroBanIntelligenceQueryError({ + operation: "fetch base team data for hero exposure", + cause: error, + }) + ), + Effect.withSpan("intelligence.heroBan.fetchBaseTeamData", { + attributes: { userTeamId }, + }) + ); + + heroExposure = computeHeroExposureSync( + maps, + baseData.allPlayerStats, + baseData.teamRosterSet + ); + } + + wideEvent.winRateDeltaCount = winRateDeltas.length; + wideEvent.comfortCrutchCount = comfortCrutches.length; + wideEvent.protectedHeroCount = protectedHeroes.length; + wideEvent.heroExposureCount = heroExposure.length; + wideEvent.banDisruptionCount = banDisruptionRanking.length; + wideEvent.outcome = "success"; + yield* Metric.increment(heroBanIntelligenceQuerySuccessTotal); + + return { + winRateDeltas, + comfortCrutches, + protectedHeroes, + banRateByMapType, + heroExposure, + banDisruptionRanking, + } satisfies HeroBanIntelligence; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(heroBanIntelligenceQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("intelligence.getHeroBanIntelligence") + : Effect.logInfo("intelligence.getHeroBanIntelligence"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + heroBanIntelligenceQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("intelligence.getHeroBanIntelligence") + ); + } + + function heroBanCacheKeyOf( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) { return JSON.stringify({ opponentAbbr, userTeamId, profile: profile ?? null }); } + + const heroBanCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { opponentAbbr, userTeamId, profile } = JSON.parse(key) as { + opponentAbbr: string; + userTeamId: number | null; + profile: DataAvailabilityProfile | null; + }; + return getHeroBanIntelligence( + opponentAbbr, + userTeamId, + profile ?? undefined + ).pipe(Effect.tap(() => Metric.increment(intelligenceCacheMissTotal))); + }, + }); + + return { + getHeroBanIntelligence: ( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) => + heroBanCache + .get(heroBanCacheKeyOf(opponentAbbr, userTeamId, profile)) + .pipe( + Effect.tap(() => Metric.increment(intelligenceCacheRequestTotal)) + ), + } satisfies HeroBanIntelligenceServiceInterface; +}); + +export const HeroBanIntelligenceServiceLive = Layer.effect( + HeroBanIntelligenceService, + make +).pipe( + Layer.provide(ScoutingServiceLive), + Layer.provide(ScrimOpponentServiceLive), + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/intelligence/index.ts b/src/data/intelligence/index.ts new file mode 100644 index 000000000..f1151c72b --- /dev/null +++ b/src/data/intelligence/index.ts @@ -0,0 +1,32 @@ +export { + HeroBanIntelligenceService, + HeroBanIntelligenceServiceLive, +} from "./hero-ban-service"; +export type { HeroBanIntelligenceServiceInterface } from "./hero-ban-service"; + +export { + MapIntelligenceService, + MapIntelligenceServiceLive, +} from "./map-service"; +export type { MapIntelligenceServiceInterface } from "./map-service"; + +export { + HeroBanIntelligenceQueryError, + MapIntelligenceQueryError, +} from "./errors"; + +export type { + HeroWinRateDelta, + ComfortCrutch, + ProtectedHero, + BanRateByMapType, + HeroExposure, + BanDisruptionEntry, + HeroBanIntelligence, + StrengthWeightedMapWR, + MapPerformanceTrend, + MapTypeDependency, + MapMatchupEntry, + MapIntelligence, + IntelligenceQueryOptions, +} from "./types"; diff --git a/src/data/intelligence/map-service.ts b/src/data/intelligence/map-service.ts new file mode 100644 index 000000000..9e33c4aa4 --- /dev/null +++ b/src/data/intelligence/map-service.ts @@ -0,0 +1,536 @@ +import { + OpponentStrengthService, + OpponentStrengthServiceLive, +} from "@/data/scouting/opponent-strength-service"; +import type { TeamStrengthRating } from "@/data/scouting/types"; +import { + ScoutingService, + ScoutingServiceLive, + type OpponentMatchRow, +} from "@/data/scouting/scouting-service"; +import { + ScrimOpponentService, + ScrimOpponentServiceLive, +} from "@/data/scrim/opponent-service"; +import { + TeamStatsService, + TeamStatsServiceLive, +} from "@/data/team/stats-service"; +import { EffectObservabilityLive } from "@/instrumentation"; +import { assessConfidence } from "@/lib/confidence"; +import { + hasScrimData, + SCRIM_CONFIDENCE_THRESHOLDS, + type DataAvailabilityProfile, +} from "@/lib/data-availability"; +import type { MapType } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapIntelligenceQueryError } from "./errors"; +import { + intelligenceCacheRequestTotal, + intelligenceCacheMissTotal, + mapIntelligenceQueryDuration, + mapIntelligenceQueryErrorTotal, + mapIntelligenceQuerySuccessTotal, +} from "./metrics"; +import type { + MapIntelligence, + MapMatchupEntry, + MapPerformanceTrend, + MapResultRow, + MapResultRowWithSource, + MapTypeDependency, + StrengthWeightedMapWR, +} from "./types"; + +const INITIAL_RATING = 1500; +const HALF_LIFE_DAYS = 90; +const DECAY_CONSTANT = Math.LN2 / HALF_LIFE_DAYS; +const RECENT_MAP_WINDOW = 10; + +function calculateWeight(matchDate: Date): number { + const daysAgo = (Date.now() - matchDate.getTime()) / (1000 * 60 * 60 * 24); + return Math.exp(-DECAY_CONSTANT * daysAgo); +} + +function getOpponentMapResults( + matches: OpponentMatchRow[], + teamAbbr: string +): MapResultRow[] { + const rows: MapResultRow[] = []; + for (const match of matches) { + const teamSide = + match.team1 === teamAbbr ? ("team1" as const) : ("team2" as const); + for (const map of match.maps) { + rows.push({ + mapName: map.mapName, + mapType: map.mapType, + matchDate: match.matchDate, + team1: match.team1, + team2: match.team2, + teamSide, + winner: map.winner, + }); + } + } + return rows; +} + +function computeStrengthWeightedWRs( + rows: MapResultRowWithSource[], + teamAbbr: string, + ratingsMap: Map, + includesScrimData: boolean +): StrengthWeightedMapWR[] { + const strengthRatingAvailable = ratingsMap.size > 0; + + const byMap = new Map< + string, + { + mapType: MapType; + results: { won: boolean; opponentRating: number; weight: number }[]; + owcsMaps: number; + scrimMaps: number; + } + >(); + + for (const row of rows) { + const won = row.winner === row.teamSide; + const opponentAbbr = row.teamSide === "team1" ? row.team2 : row.team1; + const opponentRating = + ratingsMap.get(opponentAbbr)?.rating ?? INITIAL_RATING; + const weight = calculateWeight(row.matchDate); + + let entry = byMap.get(row.mapName); + if (!entry) { + entry = { mapType: row.mapType, results: [], owcsMaps: 0, scrimMaps: 0 }; + byMap.set(row.mapName, entry); + } + entry.results.push({ won, opponentRating, weight }); + if (row.source === "scrim") { + entry.scrimMaps++; + } else { + entry.owcsMaps++; + } + } + + const confidenceThresholds = includesScrimData + ? SCRIM_CONFIDENCE_THRESHOLDS + : undefined; + + return Array.from(byMap.entries()) + .map(([mapName, { mapType, results, owcsMaps, scrimMaps }]) => { + const played = results.length; + const won = results.filter((r) => r.won).length; + const rawWinRate = played > 0 ? (won / played) * 100 : 0; + + let weightedWinSum = 0; + let weightedTotalSum = 0; + for (const r of results) { + const ratingWeight = strengthRatingAvailable + ? r.opponentRating / INITIAL_RATING + : 1; + const combinedWeight = ratingWeight * r.weight; + weightedWinSum += r.won ? combinedWeight : 0; + weightedTotalSum += combinedWeight; + } + const strengthWeightedWinRate = + weightedTotalSum > 0 + ? (weightedWinSum / weightedTotalSum) * 100 + : rawWinRate; + + return { + mapName, + mapType, + rawWinRate, + strengthWeightedWinRate: strengthRatingAvailable + ? strengthWeightedWinRate + : rawWinRate, + played, + won, + confidence: assessConfidence(played, confidenceThresholds), + strengthRatingAvailable, + sources: includesScrimData ? { owcsMaps, scrimMaps } : undefined, + }; + }) + .sort((a, b) => b.strengthWeightedWinRate - a.strengthWeightedWinRate); +} + +function computeTrends(rows: MapResultRowWithSource[]): MapPerformanceTrend[] { + const byMap = new Map(); + + for (const row of rows) { + const won = row.winner === row.teamSide; + let results = byMap.get(row.mapName); + if (!results) { + results = []; + byMap.set(row.mapName, results); + } + results.push({ won, date: row.matchDate }); + } + + return Array.from(byMap.entries()) + .map(([mapName, results]) => { + results.sort((a, b) => b.date.getTime() - a.date.getTime()); + + const overallPlayed = results.length; + const overallWon = results.filter((r) => r.won).length; + const overallWinRate = + overallPlayed > 0 ? (overallWon / overallPlayed) * 100 : 0; + + const recent = results.slice(0, RECENT_MAP_WINDOW); + const recentPlayed = recent.length; + const recentWon = recent.filter((r) => r.won).length; + const recentWinRate = + recentPlayed > 0 ? (recentWon / recentPlayed) * 100 : 0; + + const delta = recentWinRate - overallWinRate; + const TREND_THRESHOLD = 10; + const trend: MapPerformanceTrend["trend"] = + delta > TREND_THRESHOLD + ? "improving" + : delta < -TREND_THRESHOLD + ? "declining" + : "stable"; + + return { + mapName, + overallWinRate, + recentWinRate, + overallPlayed, + recentPlayed, + delta, + trend, + }; + }) + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); +} + +function computeMapTypeDependencies( + rows: MapResultRowWithSource[], + ratingsMap: Map, + includesScrimData: boolean +): MapTypeDependency[] { + const byType = new Map< + MapType, + { results: { won: boolean; opponentRating: number; weight: number }[] } + >(); + + for (const row of rows) { + const won = row.winner === row.teamSide; + const opponentAbbr = row.teamSide === "team1" ? row.team2 : row.team1; + const opponentRating = + ratingsMap.get(opponentAbbr)?.rating ?? INITIAL_RATING; + const weight = calculateWeight(row.matchDate); + + let entry = byType.get(row.mapType); + if (!entry) { + entry = { results: [] }; + byType.set(row.mapType, entry); + } + entry.results.push({ won, opponentRating, weight }); + } + + const strengthRatingAvailable = ratingsMap.size > 0; + const confidenceThresholds = includesScrimData + ? SCRIM_CONFIDENCE_THRESHOLDS + : undefined; + + return Array.from(byType.entries()) + .map(([mapType, { results }]) => { + const played = results.length; + const won = results.filter((r) => r.won).length; + const winRate = played > 0 ? (won / played) * 100 : 0; + + let weightedWinSum = 0; + let weightedTotalSum = 0; + for (const r of results) { + const ratingWeight = strengthRatingAvailable + ? r.opponentRating / INITIAL_RATING + : 1; + const combinedWeight = ratingWeight * r.weight; + weightedWinSum += r.won ? combinedWeight : 0; + weightedTotalSum += combinedWeight; + } + const strengthWeightedWinRate = + weightedTotalSum > 0 + ? (weightedWinSum / weightedTotalSum) * 100 + : winRate; + + return { + mapType, + played, + won, + winRate, + strengthWeightedWinRate: strengthRatingAvailable + ? strengthWeightedWinRate + : winRate, + confidence: assessConfidence(played, confidenceThresholds), + }; + }) + .sort((a, b) => b.winRate - a.winRate); +} + +function buildMatchupMatrix( + opponentWRs: StrengthWeightedMapWR[], + userMapWinrates: Record< + string, + { totalWinrate: number; totalWins: number; totalLosses: number } + > +): MapMatchupEntry[] { + return opponentWRs + .map((opp) => { + const userMap = userMapWinrates[opp.mapName]; + const userPlayed = userMap ? userMap.totalWins + userMap.totalLosses : 0; + const userWinRate = userMap ? userMap.totalWinrate : null; + + const netAdvantage = + userWinRate !== null ? userWinRate - opp.strengthWeightedWinRate : null; + + return { + mapName: opp.mapName, + mapType: opp.mapType, + userWinRate, + userPlayed, + opponentWinRate: opp.rawWinRate, + opponentStrengthWeightedWR: opp.strengthWeightedWinRate, + opponentPlayed: opp.played, + netAdvantage, + userConfidence: assessConfidence(userPlayed), + opponentConfidence: opp.confidence, + }; + }) + .sort((a, b) => { + if (a.netAdvantage === null && b.netAdvantage === null) return 0; + if (a.netAdvantage === null) return 1; + if (b.netAdvantage === null) return -1; + return b.netAdvantage - a.netAdvantage; + }); +} + +export type MapIntelligenceServiceInterface = { + readonly getMapIntelligence: ( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) => Effect.Effect; +}; + +export class MapIntelligenceService extends Context.Tag( + "@app/data/intelligence/MapIntelligenceService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make = Effect.gen(function* () { + const scouting = yield* ScoutingService; + const opponentStrength = yield* OpponentStrengthService; + const scrimOpponent = yield* ScrimOpponentService; + const teamStats = yield* TeamStatsService; + function getMapIntelligence( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + opponentAbbr, + userTeamId, + hasProfile: !!profile, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("opponentAbbr", opponentAbbr); + yield* Effect.annotateCurrentSpan("userTeamId", String(userTeamId)); + const [matches, allRatings] = yield* Effect.all([ + scouting.getOpponentMatchData(opponentAbbr), + opponentStrength.getTeamStrengthRatings(), + ]).pipe( + Effect.mapError( + (error) => + new MapIntelligenceQueryError({ + operation: "fetch opponent match data and strength ratings", + cause: error, + }) + ), + Effect.withSpan("intelligence.map.fetchMatchDataAndRatings", { + attributes: { opponentAbbr }, + }) + ); + + const owcsRows = getOpponentMapResults(matches, opponentAbbr); + + const owcsRowsWithSource: MapResultRowWithSource[] = owcsRows.map( + (r) => ({ + ...r, + source: "owcs" as const, + }) + ); + + let rows: MapResultRowWithSource[] = owcsRowsWithSource; + const includesScrimData = + !!profile && !!userTeamId && hasScrimData(profile); + wideEvent.includesScrimData = includesScrimData; + + if (includesScrimData && userTeamId !== null) { + const scrimResults = yield* scrimOpponent + .getOpponentScrimMapResults(userTeamId, opponentAbbr) + .pipe( + Effect.mapError( + (error) => + new MapIntelligenceQueryError({ + operation: "fetch opponent scrim map results", + cause: error, + }) + ), + Effect.withSpan("intelligence.map.fetchScrimMapResults", { + attributes: { opponentAbbr, userTeamId }, + }) + ); + + const scrimRows: MapResultRowWithSource[] = scrimResults.map((r) => ({ + mapName: r.mapName, + mapType: r.mapType, + matchDate: r.scrimDate, + team1: "user", + team2: opponentAbbr, + teamSide: "team1" as const, + winner: r.opponentWon ? "team2" : "team1", + source: "scrim" as const, + })); + rows = [...owcsRowsWithSource, ...scrimRows]; + } + + wideEvent.rowCount = rows.length; + wideEvent.owcsRowCount = owcsRowsWithSource.length; + wideEvent.scrimRowCount = rows.length - owcsRowsWithSource.length; + + const ratingsMap = new Map(allRatings.map((r) => [r.teamAbbr, r])); + wideEvent.ratingsAvailable = ratingsMap.size > 0; + + const strengthWeightedWRs = computeStrengthWeightedWRs( + rows, + opponentAbbr, + ratingsMap, + includesScrimData + ); + const trends = computeTrends(rows); + const mapTypeDependencies = computeMapTypeDependencies( + rows, + ratingsMap, + includesScrimData + ); + + let matchupMatrix: MapMatchupEntry[] = []; + if (userTeamId !== null) { + const userWinrates = yield* teamStats.getTeamWinrates(userTeamId).pipe( + Effect.mapError( + (error) => + new MapIntelligenceQueryError({ + operation: "fetch user team winrates for matchup matrix", + cause: error, + }) + ), + Effect.withSpan("intelligence.map.fetchUserWinrates", { + attributes: { userTeamId }, + }) + ); + + matchupMatrix = buildMatchupMatrix( + strengthWeightedWRs, + userWinrates.byMap + ); + } + + wideEvent.strengthWeightedWRCount = strengthWeightedWRs.length; + wideEvent.trendCount = trends.length; + wideEvent.mapTypeDependencyCount = mapTypeDependencies.length; + wideEvent.matchupMatrixCount = matchupMatrix.length; + wideEvent.outcome = "success"; + yield* Metric.increment(mapIntelligenceQuerySuccessTotal); + + return { + strengthWeightedWRs, + trends, + mapTypeDependencies, + matchupMatrix, + } satisfies MapIntelligence; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(mapIntelligenceQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("intelligence.getMapIntelligence") + : Effect.logInfo("intelligence.getMapIntelligence"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + mapIntelligenceQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("intelligence.getMapIntelligence") + ); + } + + function mapIntelCacheKeyOf( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) { return JSON.stringify({ opponentAbbr, userTeamId, profile: profile ?? null }); } + + const mapIntelCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { opponentAbbr, userTeamId, profile } = JSON.parse(key) as { + opponentAbbr: string; + userTeamId: number | null; + profile: DataAvailabilityProfile | null; + }; + return getMapIntelligence( + opponentAbbr, + userTeamId, + profile ?? undefined + ).pipe(Effect.tap(() => Metric.increment(intelligenceCacheMissTotal))); + }, + }); + + return { + getMapIntelligence: ( + opponentAbbr: string, + userTeamId: number | null, + profile?: DataAvailabilityProfile + ) => + mapIntelCache + .get(mapIntelCacheKeyOf(opponentAbbr, userTeamId, profile)) + .pipe( + Effect.tap(() => Metric.increment(intelligenceCacheRequestTotal)) + ), + } satisfies MapIntelligenceServiceInterface; +}); + +export const MapIntelligenceServiceLive = Layer.effect( + MapIntelligenceService, + make +).pipe( + Layer.provide(ScoutingServiceLive), + Layer.provide(OpponentStrengthServiceLive), + Layer.provide(ScrimOpponentServiceLive), + Layer.provide(TeamStatsServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/intelligence/metrics.ts b/src/data/intelligence/metrics.ts new file mode 100644 index 000000000..304757d71 --- /dev/null +++ b/src/data/intelligence/metrics.ts @@ -0,0 +1,67 @@ +import { Metric, MetricBoundaries } from "effect"; + +// Hero Ban Intelligence + +export const heroBanIntelligenceQuerySuccessTotal = Metric.counter( + "intelligence.hero_ban.query.success", + { + description: "Total successful hero ban intelligence queries", + incremental: true, + } +); + +export const heroBanIntelligenceQueryErrorTotal = Metric.counter( + "intelligence.hero_ban.query.error", + { + description: "Total hero ban intelligence query failures", + incremental: true, + } +); + +export const heroBanIntelligenceQueryDuration = Metric.histogram( + "intelligence.hero_ban.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of hero ban intelligence query duration in milliseconds" +); + +// Map Intelligence + +export const mapIntelligenceQuerySuccessTotal = Metric.counter( + "intelligence.map.query.success", + { + description: "Total successful map intelligence queries", + incremental: true, + } +); + +export const mapIntelligenceQueryErrorTotal = Metric.counter( + "intelligence.map.query.error", + { + description: "Total map intelligence query failures", + incremental: true, + } +); + +export const mapIntelligenceQueryDuration = Metric.histogram( + "intelligence.map.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of map intelligence query duration in milliseconds" +); + +// cache metrics + +export const intelligenceCacheRequestTotal = Metric.counter( + "intelligence.cache.request", + { + description: "Total intelligence data cache requests", + incremental: true, + } +); + +export const intelligenceCacheMissTotal = Metric.counter( + "intelligence.cache.miss", + { + description: "Total intelligence data cache misses (triggered lookup)", + incremental: true, + } +); diff --git a/src/data/intelligence/types.ts b/src/data/intelligence/types.ts new file mode 100644 index 000000000..008a6549d --- /dev/null +++ b/src/data/intelligence/types.ts @@ -0,0 +1,155 @@ +import type { ConfidenceMetadata } from "@/lib/confidence"; +import type { DataAvailabilityProfile } from "@/lib/data-availability"; +import type { MapType } from "@prisma/client"; + +// Hero Ban Intelligence types + +export type HeroWinRateDelta = { + hero: string; + winRateWhenAvailable: number; + winRateWhenBanned: number; + delta: number; + mapsAvailable: number; + mapsBanned: number; + confidenceAvailable: ConfidenceMetadata; + confidenceBanned: ConfidenceMetadata; +}; + +export type ComfortCrutch = { + hero: string; + banFrequency: number; + totalMaps: number; + banRate: number; + winRateDelta: number; + crutchScore: number; + confidence: ConfidenceMetadata; +}; + +export type ProtectedHero = { + hero: string; + timesBannedByTeam: number; + totalMaps: number; + banRate: number; +}; + +export type BanRateByMapType = { + hero: string; + mapType: MapType; + banCount: number; + totalMapsOfType: number; + banRate: number; +}; + +export type HeroExposure = { + hero: string; + userPlayRate: number; + userTimePlayed: number; + opponentBanRate: number; + opponentBanCount: number; + exposureRisk: "high" | "medium" | "low"; +}; + +export type BanDisruptionEntry = { + hero: string; + winRateDelta: number; + mapsAvailable: number; + mapsBanned: number; + disruptionScore: number; + confidence: ConfidenceMetadata; +}; + +export type HeroBanIntelligence = { + winRateDeltas: HeroWinRateDelta[]; + comfortCrutches: ComfortCrutch[]; + protectedHeroes: ProtectedHero[]; + banRateByMapType: BanRateByMapType[]; + heroExposure: HeroExposure[]; + banDisruptionRanking: BanDisruptionEntry[]; +}; + +// Map Intelligence types + +export type StrengthWeightedMapWR = { + mapName: string; + mapType: MapType; + rawWinRate: number; + strengthWeightedWinRate: number; + played: number; + won: number; + confidence: ConfidenceMetadata; + strengthRatingAvailable: boolean; + sources?: { owcsMaps: number; scrimMaps: number }; +}; + +export type MapPerformanceTrend = { + mapName: string; + overallWinRate: number; + recentWinRate: number; + overallPlayed: number; + recentPlayed: number; + delta: number; + trend: "improving" | "declining" | "stable"; +}; + +export type MapTypeDependency = { + mapType: MapType; + played: number; + won: number; + winRate: number; + strengthWeightedWinRate: number; + confidence: ConfidenceMetadata; +}; + +export type MapMatchupEntry = { + mapName: string; + mapType: MapType; + userWinRate: number | null; + userPlayed: number; + opponentWinRate: number; + opponentStrengthWeightedWR: number; + opponentPlayed: number; + netAdvantage: number | null; + userConfidence: ConfidenceMetadata; + opponentConfidence: ConfidenceMetadata; +}; + +export type MapIntelligence = { + strengthWeightedWRs: StrengthWeightedMapWR[]; + trends: MapPerformanceTrend[]; + mapTypeDependencies: MapTypeDependency[]; + matchupMatrix: MapMatchupEntry[]; +}; + +// Shared internal types + +export type MapWithBans = { + mapName: string; + mapType: MapType; + matchDate: Date; + teamSide: "team1" | "team2"; + won: boolean; + heroBans: { team: string; hero: string }[]; + source: "owcs" | "scrim"; +}; + +export type MapResultRow = { + mapName: string; + mapType: MapType; + matchDate: Date; + team1: string; + team2: string; + teamSide: "team1" | "team2"; + winner: string; +}; + +export type MapResultRowWithSource = MapResultRow & { + source: "owcs" | "scrim"; +}; + +// Common query options + +export type IntelligenceQueryOptions = { + opponentAbbr: string; + userTeamId: number | null; + profile?: DataAvailabilityProfile; +}; diff --git a/src/data/scouting/errors.ts b/src/data/scouting/errors.ts new file mode 100644 index 000000000..bc4cc33be --- /dev/null +++ b/src/data/scouting/errors.ts @@ -0,0 +1,24 @@ +import { Schema as S } from "effect"; + +export class ScoutingQueryError extends S.TaggedError()( + "ScoutingQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Scouting query failed: ${this.operation}`; + } +} + +export class ScoutingTeamNotFoundError extends S.TaggedError()( + "ScoutingTeamNotFoundError", + { + teamAbbr: S.String, + } +) { + get message(): string { + return `Scouting team not found: ${this.teamAbbr}`; + } +} diff --git a/src/data/scouting/index.ts b/src/data/scouting/index.ts new file mode 100644 index 000000000..2b3d6b908 --- /dev/null +++ b/src/data/scouting/index.ts @@ -0,0 +1,28 @@ +export { ScoutingService, ScoutingServiceLive } from "./scouting-service"; +export type { + ScoutingServiceInterface, + OpponentMatchRow, +} from "./scouting-service"; + +export { + OpponentStrengthService, + OpponentStrengthServiceLive, +} from "./opponent-strength-service"; +export type { OpponentStrengthServiceInterface } from "./opponent-strength-service"; + +export { ScoutingQueryError, ScoutingTeamNotFoundError } from "./errors"; + +export type { + MatchResult, + ScoutingTeam, + ScoutingTeamOverview, + HeroBanEntry, + ScoutingHeroBans, + MapPerformanceEntry, + ScoutingMapAnalysis, + ScoutingMatchHistoryEntry, + ScoutingRecommendation, + ScoutingRecommendations, + ScoutingTeamProfile, + TeamStrengthRating, +} from "./types"; diff --git a/src/data/scouting/metrics.ts b/src/data/scouting/metrics.ts new file mode 100644 index 000000000..4c34e60f1 --- /dev/null +++ b/src/data/scouting/metrics.ts @@ -0,0 +1,148 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const scoutingTeamsQuerySuccessTotal = Metric.counter( + "scouting.teams.query.success", + { + description: "Total successful scouting teams queries", + incremental: true, + } +); + +export const scoutingTeamsQueryErrorTotal = Metric.counter( + "scouting.teams.query.error", + { + description: "Total scouting teams query failures", + incremental: true, + } +); + +export const scoutingTeamsQueryDuration = Metric.histogram( + "scouting.teams.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scouting teams query duration in milliseconds" +); + +export const scoutingOpponentMatchDataQuerySuccessTotal = Metric.counter( + "scouting.opponent_match_data.query.success", + { + description: "Total successful opponent match data queries", + incremental: true, + } +); + +export const scoutingOpponentMatchDataQueryErrorTotal = Metric.counter( + "scouting.opponent_match_data.query.error", + { + description: "Total opponent match data query failures", + incremental: true, + } +); + +export const scoutingOpponentMatchDataQueryDuration = Metric.histogram( + "scouting.opponent_match_data.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of opponent match data query duration in milliseconds" +); + +export const scoutingTeamProfileQuerySuccessTotal = Metric.counter( + "scouting.team_profile.query.success", + { + description: "Total successful scouting team profile queries", + incremental: true, + } +); + +export const scoutingTeamProfileQueryErrorTotal = Metric.counter( + "scouting.team_profile.query.error", + { + description: "Total scouting team profile query failures", + incremental: true, + } +); + +export const scoutingTeamProfileQueryDuration = Metric.histogram( + "scouting.team_profile.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scouting team profile query duration in milliseconds" +); + +export const strengthRatingsQuerySuccessTotal = Metric.counter( + "scouting.strength_ratings.query.success", + { + description: "Total successful team strength ratings queries", + incremental: true, + } +); + +export const strengthRatingsQueryErrorTotal = Metric.counter( + "scouting.strength_ratings.query.error", + { + description: "Total team strength ratings query failures", + incremental: true, + } +); + +export const strengthRatingsQueryDuration = Metric.histogram( + "scouting.strength_ratings.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team strength ratings query duration in milliseconds" +); + +export const strengthRatingQuerySuccessTotal = Metric.counter( + "scouting.strength_rating.query.success", + { + description: "Total successful single team strength rating queries", + incremental: true, + } +); + +export const strengthRatingQueryErrorTotal = Metric.counter( + "scouting.strength_rating.query.error", + { + description: "Total single team strength rating query failures", + incremental: true, + } +); + +export const strengthRatingQueryDuration = Metric.histogram( + "scouting.strength_rating.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of single team strength rating query duration in milliseconds" +); + +export const strengthPercentileQuerySuccessTotal = Metric.counter( + "scouting.strength_percentile.query.success", + { + description: "Total successful team strength percentile queries", + incremental: true, + } +); + +export const strengthPercentileQueryErrorTotal = Metric.counter( + "scouting.strength_percentile.query.error", + { + description: "Total team strength percentile query failures", + incremental: true, + } +); + +export const strengthPercentileQueryDuration = Metric.histogram( + "scouting.strength_percentile.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team strength percentile query duration in milliseconds" +); + +// cache metrics + +export const scoutingCacheRequestTotal = Metric.counter( + "scouting.cache.request", + { + description: "Total scouting data cache requests", + incremental: true, + } +); + +export const scoutingCacheMissTotal = Metric.counter("scouting.cache.miss", { + description: "Total scouting data cache misses (triggered lookup)", + incremental: true, +}); diff --git a/src/data/scouting/opponent-strength-service.ts b/src/data/scouting/opponent-strength-service.ts new file mode 100644 index 000000000..24e2550ce --- /dev/null +++ b/src/data/scouting/opponent-strength-service.ts @@ -0,0 +1,355 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScoutingQueryError } from "./errors"; +import { + scoutingCacheRequestTotal, + scoutingCacheMissTotal, + strengthPercentileQueryDuration, + strengthPercentileQueryErrorTotal, + strengthPercentileQuerySuccessTotal, + strengthRatingQueryDuration, + strengthRatingQueryErrorTotal, + strengthRatingQuerySuccessTotal, + strengthRatingsQueryDuration, + strengthRatingsQueryErrorTotal, + strengthRatingsQuerySuccessTotal, +} from "./metrics"; +import type { TeamStrengthRating } from "./types"; + +const INITIAL_RATING = 1500; + +/** + * K-factor decays as sample size grows. Early matches have more volatility; + * established teams converge toward stable ratings. + */ +function kFactor(matchesPlayed: number): number { + if (matchesPlayed < 5) return 48; + if (matchesPlayed < 15) return 32; + if (matchesPlayed < 30) return 24; + return 16; +} + +function expectedScore(ratingA: number, ratingB: number): number { + return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); +} + +type MatchRecord = { + matchDate: Date; + team1: string; + team1FullName: string; + team2: string; + team2FullName: string; + winner: string | null; +}; + +export type OpponentStrengthServiceInterface = { + readonly getTeamStrengthRatings: () => Effect.Effect< + TeamStrengthRating[], + ScoutingQueryError + >; + + readonly getTeamStrengthRating: ( + teamAbbr: string + ) => Effect.Effect; + + readonly getTeamStrengthPercentile: ( + teamAbbr: string + ) => Effect.Effect; +}; + +export class OpponentStrengthService extends Context.Tag( + "@app/data/scouting/OpponentStrengthService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getTeamStrengthRatings(): Effect.Effect< + TeamStrengthRating[], + ScoutingQueryError + > { + const startTime = Date.now(); + const wideEvent: Record = {}; + + return Effect.gen(function* () { + const matches = yield* Effect.tryPromise({ + try: () => + prisma.scoutingMatch.findMany({ + select: { + matchDate: true, + team1: true, + team1FullName: true, + team2: true, + team2FullName: true, + winner: true, + }, + orderBy: { matchDate: "asc" }, + }), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch matches for strength ratings", + cause: error, + }), + }).pipe( + Effect.withSpan("scouting.strengthRatings.fetchMatches", { + attributes: {}, + }) + ); + + const ratings = new Map(); + const fullNames = new Map(); + const matchCounts = new Map(); + const histories = new Map(); + + function ensureTeam(abbr: string, name: string) { + if (!ratings.has(abbr)) { + ratings.set(abbr, INITIAL_RATING); + fullNames.set(abbr, name); + matchCounts.set(abbr, 0); + histories.set(abbr, []); + } + } + + function recordHistory(abbr: string, date: Date) { + const history = histories.get(abbr)!; + history.push({ date, rating: ratings.get(abbr)! }); + } + + for (const match of matches as MatchRecord[]) { + ensureTeam(match.team1, match.team1FullName); + ensureTeam(match.team2, match.team2FullName); + + if (!match.winner) continue; + + const r1 = ratings.get(match.team1)!; + const r2 = ratings.get(match.team2)!; + const n1 = matchCounts.get(match.team1)!; + const n2 = matchCounts.get(match.team2)!; + + const e1 = expectedScore(r1, r2); + const e2 = 1 - e1; + const s1 = match.winner === match.team1 ? 1 : 0; + const s2 = 1 - s1; + + const k1 = kFactor(n1); + const k2 = kFactor(n2); + + ratings.set(match.team1, r1 + k1 * (s1 - e1)); + ratings.set(match.team2, r2 + k2 * (s2 - e2)); + + matchCounts.set(match.team1, n1 + 1); + matchCounts.set(match.team2, n2 + 1); + + recordHistory(match.team1, match.matchDate); + recordHistory(match.team2, match.matchDate); + } + + const result = Array.from(ratings.entries()) + .map(([teamAbbr, rating]) => ({ + teamAbbr, + fullName: fullNames.get(teamAbbr)!, + rating: Math.round(rating), + matchesRated: matchCounts.get(teamAbbr)!, + ratingHistory: histories.get(teamAbbr)!, + })) + .sort((a, b) => b.rating - a.rating); + + wideEvent.team_count = result.length; + wideEvent.match_count = matches.length; + wideEvent.outcome = "success"; + yield* Metric.increment(strengthRatingsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(strengthRatingsQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getTeamStrengthRatings") + : Effect.logInfo("scouting.getTeamStrengthRatings"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + strengthRatingsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scouting.getTeamStrengthRatings") + ); + } + + function getTeamStrengthRating( + teamAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamAbbr", teamAbbr); + const ratings = yield* getTeamStrengthRatings(); + const found = ratings.find((r) => r.teamAbbr === teamAbbr) ?? null; + + wideEvent.found = found !== null; + if (found) { + wideEvent.rating = found.rating; + wideEvent.matches_rated = found.matchesRated; + } + wideEvent.outcome = "success"; + yield* Metric.increment(strengthRatingQuerySuccessTotal); + return found; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(strengthRatingQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getTeamStrengthRating") + : Effect.logInfo("scouting.getTeamStrengthRating"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + strengthRatingQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scouting.getTeamStrengthRating") + ); + } + + function getTeamStrengthPercentile( + teamAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamAbbr", teamAbbr); + const ratings = yield* getTeamStrengthRatings(); + const index = ratings.findIndex((r) => r.teamAbbr === teamAbbr); + + if (index === -1) { + wideEvent.found = false; + wideEvent.outcome = "success"; + yield* Metric.increment(strengthPercentileQuerySuccessTotal); + return null; + } + + const percentile = + ratings.length <= 1 + ? 100 + : Math.round( + ((ratings.length - 1 - index) / (ratings.length - 1)) * 100 + ); + + wideEvent.found = true; + wideEvent.percentile = percentile; + wideEvent.total_teams = ratings.length; + wideEvent.outcome = "success"; + yield* Metric.increment(strengthPercentileQuerySuccessTotal); + return percentile; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(strengthPercentileQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getTeamStrengthPercentile") + : Effect.logInfo("scouting.getTeamStrengthPercentile"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + strengthPercentileQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scouting.getTeamStrengthPercentile") + ); + } + + const strengthRatingsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (_key: string) => + getTeamStrengthRatings().pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + const strengthRatingCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamAbbr: string) => + getTeamStrengthRating(teamAbbr).pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + const strengthPercentileCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamAbbr: string) => + getTeamStrengthPercentile(teamAbbr).pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + return { + getTeamStrengthRatings: () => + strengthRatingsCache + .get("__all__") + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + getTeamStrengthRating: (teamAbbr: string) => + strengthRatingCache + .get(teamAbbr) + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + getTeamStrengthPercentile: (teamAbbr: string) => + strengthPercentileCache + .get(teamAbbr) + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + } satisfies OpponentStrengthServiceInterface; + } +); + +export const OpponentStrengthServiceLive = Layer.effect( + OpponentStrengthService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/scouting/scouting-service.ts b/src/data/scouting/scouting-service.ts new file mode 100644 index 000000000..4cb984698 --- /dev/null +++ b/src/data/scouting/scouting-service.ts @@ -0,0 +1,556 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { MapType, Prisma } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScoutingQueryError } from "./errors"; +import { + scoutingCacheRequestTotal, + scoutingCacheMissTotal, + scoutingOpponentMatchDataQueryDuration, + scoutingOpponentMatchDataQueryErrorTotal, + scoutingOpponentMatchDataQuerySuccessTotal, + scoutingTeamProfileQueryDuration, + scoutingTeamProfileQueryErrorTotal, + scoutingTeamProfileQuerySuccessTotal, + scoutingTeamsQueryDuration, + scoutingTeamsQueryErrorTotal, + scoutingTeamsQuerySuccessTotal, +} from "./metrics"; +import type { + HeroBanEntry, + MapPerformanceEntry, + ScoutingHeroBans, + ScoutingMapAnalysis, + ScoutingMatchHistoryEntry, + ScoutingRecommendation, + ScoutingRecommendations, + ScoutingTeam, + ScoutingTeamOverview, + ScoutingTeamProfile, +} from "./types"; + +const HALF_LIFE_DAYS = 90; +const DECAY_CONSTANT = Math.LN2 / HALF_LIFE_DAYS; + +function calculateWeight(matchDate: Date): number { + const daysAgo = (Date.now() - matchDate.getTime()) / (1000 * 60 * 60 * 24); + return Math.exp(-DECAY_CONSTANT * daysAgo); +} + +function weightedRate(items: { won: boolean; weight: number }[]): number { + if (items.length === 0) return 0; + const totalWeight = items.reduce((sum, i) => sum + i.weight, 0); + if (totalWeight === 0) return 0; + const winWeight = items + .filter((i) => i.won) + .reduce((sum, i) => sum + i.weight, 0); + return (winWeight / totalWeight) * 100; +} + +const opponentMatchInclude = { + maps: { include: { heroBans: true } }, + tournament: { select: { title: true } }, +} satisfies Prisma.ScoutingMatchInclude; + +export type OpponentMatchRow = Prisma.ScoutingMatchGetPayload<{ + include: typeof opponentMatchInclude; +}>; + +type TeamAppearanceRow = { + team: string; + team_full_name: string; + match_count: bigint; + win_count: bigint; +}; + +export type ScoutingServiceInterface = { + readonly getScoutingTeams: () => Effect.Effect< + ScoutingTeam[], + ScoutingQueryError + >; + + readonly getOpponentMatchData: ( + teamAbbr: string + ) => Effect.Effect; + + readonly getScoutingTeamProfile: ( + teamAbbr: string + ) => Effect.Effect; +}; + +export class ScoutingService extends Context.Tag( + "@app/data/scouting/ScoutingService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getScoutingTeams(): Effect.Effect< + ScoutingTeam[], + ScoutingQueryError + > { + const startTime = Date.now(); + const wideEvent: Record = {}; + + return Effect.gen(function* () { + const rows = yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH appearances AS ( + SELECT team1 AS team, "team1FullName" AS team_full_name, id, + CASE WHEN winner = team1 THEN 1 ELSE 0 END AS won + FROM "ScoutingMatch" + UNION ALL + SELECT team2 AS team, "team2FullName" AS team_full_name, id, + CASE WHEN winner = team2 THEN 1 ELSE 0 END AS won + FROM "ScoutingMatch" + ) + SELECT + team, + team_full_name, + COUNT(*)::bigint AS match_count, + SUM(won)::bigint AS win_count + FROM appearances + GROUP BY team, team_full_name + ORDER BY match_count DESC + `, + catch: (error) => + new ScoutingQueryError({ + operation: "fetch scouting teams", + cause: error, + }), + }).pipe( + Effect.withSpan("scouting.teams.fetchRows", { + attributes: {}, + }) + ); + + const teams: ScoutingTeam[] = rows.map((row) => ({ + abbreviation: row.team, + fullName: row.team_full_name, + matchCount: Number(row.match_count), + winCount: Number(row.win_count), + })); + + wideEvent.team_count = teams.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingTeamsQuerySuccessTotal); + return teams; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scoutingTeamsQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getScoutingTeams") + : Effect.logInfo("scouting.getScoutingTeams"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scoutingTeamsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scouting.getScoutingTeams") + ); + } + + function getOpponentMatchData( + teamAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamAbbr", teamAbbr); + const matches = yield* Effect.tryPromise({ + try: () => + prisma.scoutingMatch.findMany({ + where: { OR: [{ team1: teamAbbr }, { team2: teamAbbr }] }, + include: opponentMatchInclude, + orderBy: { matchDate: "asc" }, + }), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch opponent match data", + cause: error, + }), + }).pipe( + Effect.withSpan("scouting.opponentMatchData.fetch", { + attributes: { teamAbbr }, + }) + ); + + wideEvent.match_count = matches.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingOpponentMatchDataQuerySuccessTotal); + return matches; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scoutingOpponentMatchDataQueryErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getOpponentMatchData") + : Effect.logInfo("scouting.getOpponentMatchData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scoutingOpponentMatchDataQueryDuration( + Effect.succeed(durationMs) + ) + ) + ); + }) + ), + Effect.withSpan("scouting.getOpponentMatchData") + ); + } + + function getScoutingTeamProfile( + teamAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamAbbr", teamAbbr); + const matchesAsc = yield* getOpponentMatchData(teamAbbr); + + if (matchesAsc.length === 0) { + wideEvent.match_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingTeamProfileQuerySuccessTotal); + return null; + } + + const matches = [...matchesAsc].reverse(); + + const firstMatch = matches[0]; + const fullName = + firstMatch.team1 === teamAbbr + ? firstMatch.team1FullName + : firstMatch.team2FullName; + + type ProcessedMatch = { + isTeam1: boolean; + won: boolean; + weight: number; + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + tournament: string; + maps: typeof firstMatch.maps; + }; + + const processed: ProcessedMatch[] = matches.map((m) => { + const isTeam1 = m.team1 === teamAbbr; + const won = m.winner === teamAbbr; + return { + isTeam1, + won, + weight: calculateWeight(m.matchDate), + date: m.matchDate, + opponent: isTeam1 ? m.team2 : m.team1, + opponentFullName: isTeam1 ? m.team2FullName : m.team1FullName, + teamScore: isTeam1 ? m.team1Score : m.team2Score, + opponentScore: isTeam1 ? m.team2Score : m.team1Score, + tournament: m.tournament.title, + maps: m.maps, + }; + }); + + // Overview + const wins = processed.filter((m) => m.won).length; + const losses = processed.length - wins; + const overview: ScoutingTeamOverview = { + totalMatches: processed.length, + wins, + losses, + winRate: processed.length > 0 ? (wins / processed.length) * 100 : 0, + weightedWinRate: weightedRate(processed), + recentForm: processed + .slice(0, 10) + .map((m) => (m.won ? "win" : "loss")), + }; + + // Hero bans + const bansByTeamMap = new Map< + string, + { raw: number; weighted: number } + >(); + const bansAgainstMap = new Map< + string, + { raw: number; weighted: number } + >(); + + for (const match of processed) { + const teamSide = match.isTeam1 ? "team1" : "team2"; + + for (const map of match.maps) { + for (const ban of map.heroBans) { + const target = + ban.team === teamSide ? bansByTeamMap : bansAgainstMap; + const existing = target.get(ban.hero) ?? { + raw: 0, + weighted: 0, + }; + existing.raw += 1; + existing.weighted += match.weight; + target.set(ban.hero, existing); + } + } + } + + function toBanEntries( + map: Map + ): HeroBanEntry[] { + return Array.from(map.entries()) + .map(([hero, data]) => ({ + hero, + rawCount: data.raw, + weightedCount: Math.round(data.weighted * 100) / 100, + })) + .sort((a, b) => b.weightedCount - a.weightedCount); + } + + const heroBans: ScoutingHeroBans = { + bansByTeam: toBanEntries(bansByTeamMap), + bansAgainstTeam: toBanEntries(bansAgainstMap), + }; + + // Map analysis + type MapAccum = { played: { won: boolean; weight: number }[] }; + const byMapName = new Map(); + const byMapTypeMap = new Map(); + + for (const match of processed) { + const teamSide = match.isTeam1 ? "team1" : "team2"; + for (const map of match.maps) { + const mapWon = map.winner === teamSide; + const entry = { won: mapWon, weight: match.weight }; + + const nameAccum = byMapName.get(map.mapName) ?? { played: [] }; + nameAccum.played.push(entry); + byMapName.set(map.mapName, nameAccum); + + const typeAccum = byMapTypeMap.get(map.mapType) ?? { + played: [], + }; + typeAccum.played.push(entry); + byMapTypeMap.set(map.mapType, typeAccum); + } + } + + function toMapPerformance( + accum: MapAccum + ): Pick< + MapPerformanceEntry, + "played" | "won" | "winRate" | "weightedWinRate" + > { + const won = accum.played.filter((p) => p.won).length; + return { + played: accum.played.length, + won, + winRate: + accum.played.length > 0 ? (won / accum.played.length) * 100 : 0, + weightedWinRate: weightedRate(accum.played), + }; + } + + const mapAnalysis: ScoutingMapAnalysis = { + byMap: Array.from(byMapName.entries()) + .map(([name, accum]) => ({ name, ...toMapPerformance(accum) })) + .sort((a, b) => b.played - a.played), + byMapType: Array.from(byMapTypeMap.entries()) + .map(([mapType, accum]) => ({ + name: mapType, + mapType, + ...toMapPerformance(accum), + })) + .sort((a, b) => b.played - a.played), + }; + + // Match history + const matchHistory: ScoutingMatchHistoryEntry[] = processed.map( + (m) => ({ + date: m.date, + opponent: m.opponent, + opponentFullName: m.opponentFullName, + teamScore: m.teamScore, + opponentScore: m.opponentScore, + result: m.won ? "win" : "loss", + tournament: m.tournament, + }) + ); + + // Recommendations + const suggestedBans: ScoutingRecommendation[] = heroBans.bansAgainstTeam + .slice(0, 5) + .map((ban) => ({ + name: ban.hero, + reason: `Banned against ${teamAbbr} ${ban.rawCount} times (opponents target this hero)`, + weightedWinRate: ban.weightedCount, + sampleSize: ban.rawCount, + })); + + const MIN_MAP_SAMPLE = 3; + const mapsWithEnoughData = mapAnalysis.byMap.filter( + (m) => m.played >= MIN_MAP_SAMPLE + ); + + const suggestedMapPicks: ScoutingRecommendation[] = [ + ...mapsWithEnoughData, + ] + .sort((a, b) => a.weightedWinRate - b.weightedWinRate) + .slice(0, 3) + .map((m) => ({ + name: m.name, + reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, + weightedWinRate: m.weightedWinRate, + sampleSize: m.played, + })); + + const suggestedMapAvoids: ScoutingRecommendation[] = [ + ...mapsWithEnoughData, + ] + .sort((a, b) => b.weightedWinRate - a.weightedWinRate) + .slice(0, 3) + .map((m) => ({ + name: m.name, + reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, + weightedWinRate: m.weightedWinRate, + sampleSize: m.played, + })); + + const recommendations: ScoutingRecommendations = { + suggestedBans, + suggestedMapPicks, + suggestedMapAvoids, + }; + + wideEvent.match_count = processed.length; + wideEvent.wins = wins; + wideEvent.losses = losses; + wideEvent.ban_count = + heroBans.bansByTeam.length + heroBans.bansAgainstTeam.length; + wideEvent.map_analysis_count = mapAnalysis.byMap.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingTeamProfileQuerySuccessTotal); + + return { + team: { abbreviation: teamAbbr, fullName }, + overview, + heroBans, + mapAnalysis, + matchHistory, + recommendations, + } satisfies ScoutingTeamProfile; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scoutingTeamProfileQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scouting.getScoutingTeamProfile") + : Effect.logInfo("scouting.getScoutingTeamProfile"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scoutingTeamProfileQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scouting.getScoutingTeamProfile") + ); + } + + // Use a constant key for the no-arg getScoutingTeams + const scoutingTeamsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (_key: string) => + getScoutingTeams().pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + const opponentMatchDataCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamAbbr: string) => + getOpponentMatchData(teamAbbr).pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + const teamProfileCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamAbbr: string) => + getScoutingTeamProfile(teamAbbr).pipe( + Effect.tap(() => Metric.increment(scoutingCacheMissTotal)) + ), + }); + + return { + getScoutingTeams: () => + scoutingTeamsCache + .get("__all__") + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + getOpponentMatchData: (teamAbbr: string) => + opponentMatchDataCache + .get(teamAbbr) + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + getScoutingTeamProfile: (teamAbbr: string) => + teamProfileCache + .get(teamAbbr) + .pipe(Effect.tap(() => Metric.increment(scoutingCacheRequestTotal))), + } satisfies ScoutingServiceInterface; + } +); + +export const ScoutingServiceLive = Layer.effect(ScoutingService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/scouting/types.ts b/src/data/scouting/types.ts new file mode 100644 index 000000000..e8ce90885 --- /dev/null +++ b/src/data/scouting/types.ts @@ -0,0 +1,83 @@ +import type { MapType } from "@prisma/client"; + +export type MatchResult = "win" | "loss"; + +export type ScoutingTeam = { + abbreviation: string; + fullName: string; + matchCount: number; + winCount: number; +}; + +export type ScoutingTeamOverview = { + totalMatches: number; + wins: number; + losses: number; + winRate: number; + weightedWinRate: number; + recentForm: MatchResult[]; +}; + +export type HeroBanEntry = { + hero: string; + rawCount: number; + weightedCount: number; +}; + +export type ScoutingHeroBans = { + bansByTeam: HeroBanEntry[]; + bansAgainstTeam: HeroBanEntry[]; +}; + +export type MapPerformanceEntry = { + name: string; + played: number; + won: number; + winRate: number; + weightedWinRate: number; +}; + +export type ScoutingMapAnalysis = { + byMap: MapPerformanceEntry[]; + byMapType: (MapPerformanceEntry & { mapType: MapType })[]; +}; + +export type ScoutingMatchHistoryEntry = { + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + result: MatchResult; + tournament: string; +}; + +export type ScoutingRecommendation = { + name: string; + reason: string; + weightedWinRate: number; + sampleSize: number; +}; + +export type ScoutingRecommendations = { + suggestedBans: ScoutingRecommendation[]; + suggestedMapPicks: ScoutingRecommendation[]; + suggestedMapAvoids: ScoutingRecommendation[]; +}; + +export type ScoutingTeamProfile = { + team: { abbreviation: string; fullName: string }; + overview: ScoutingTeamOverview; + heroBans: ScoutingHeroBans; + mapAnalysis: ScoutingMapAnalysis; + matchHistory: ScoutingMatchHistoryEntry[]; + recommendations: ScoutingRecommendations; +}; + +export type TeamStrengthRating = { + teamAbbr: string; + fullName: string; + rating: number; + matchesRated: number; + ratingHistory: { date: Date; rating: number }[]; +}; From 4c7139714ad6c47fd7f3bac1435227956ad67439 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:10 -0400 Subject: [PATCH 10/42] Add scrim Effect services Covers scrim metadata, overview analysis, opponent data, ability timing, fight timelines, and per-player stats/winrates. Overview and opponent services consume TeamSharedDataService and ScrimAbilityTimingService via Effect Context. Includes ult-helpers for shared pure functions. --- src/data/scrim/ability-timing-service.ts | 1009 ++++++++++ src/data/scrim/errors.ts | 24 + src/data/scrim/index.ts | 31 + src/data/scrim/metrics.ts | 355 ++++ src/data/scrim/opponent-service.ts | 853 ++++++++ src/data/scrim/overview-service.ts | 2335 ++++++++++++++++++++++ src/data/scrim/scrim-service.ts | 1100 ++++++++++ src/data/scrim/types.ts | 323 +++ src/data/scrim/ult-helpers.ts | 158 ++ 9 files changed, 6188 insertions(+) create mode 100644 src/data/scrim/ability-timing-service.ts create mode 100644 src/data/scrim/errors.ts create mode 100644 src/data/scrim/index.ts create mode 100644 src/data/scrim/metrics.ts create mode 100644 src/data/scrim/opponent-service.ts create mode 100644 src/data/scrim/overview-service.ts create mode 100644 src/data/scrim/scrim-service.ts create mode 100644 src/data/scrim/types.ts create mode 100644 src/data/scrim/ult-helpers.ts diff --git a/src/data/scrim/ability-timing-service.ts b/src/data/scrim/ability-timing-service.ts new file mode 100644 index 000000000..fa2e01a83 --- /dev/null +++ b/src/data/scrim/ability-timing-service.ts @@ -0,0 +1,1009 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { + groupEventsIntoFights, + mercyRezToKillEvent, + removeDuplicateRows, + type Fight, +} from "@/lib/utils"; +import type { AbilityImpact } from "@/types/heroes"; +import { allHeroes } from "@/types/heroes"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScrimQueryError } from "./errors"; +import { + scrimAbilityTimingDuration, + scrimAbilityTimingErrorTotal, + scrimAbilityTimingSuccessTotal, + scrimCacheRequestTotal, + scrimCacheMissTotal, + scrimFightTimelinesDuration, + scrimFightTimelinesErrorTotal, + scrimFightTimelinesSuccessTotal, + scrimMapAbilityTimingDuration, + scrimMapAbilityTimingErrorTotal, + scrimMapAbilityTimingSuccessTotal, +} from "./metrics"; +import type { + FightPhase, + AbilityPhaseStats, + AbilityTimingRow, + AbilityTimingOutlier, + AbilityTimingAnalysis, + FightAbilityEvent, + FightKillEvent, + FightTimeline, + ScrimFightTimelines, + MapAbilityTimingAnalysis, +} from "./types"; + +export type { + FightPhase, + AbilityPhaseStats, + AbilityTimingRow, + AbilityTimingOutlier, + AbilityTimingAnalysis, + FightAbilityEvent, + FightKillEvent, + FightTimeline, + ScrimFightTimelines, + MapAbilityTimingAnalysis, +} from "./types"; + +type AbilityDef = { + name: string; + impact: AbilityImpact; +}; + +const heroAbilityLookup = new Map< + string, + { ability1: AbilityDef; ability2: AbilityDef } +>(); +for (const hero of allHeroes) { + heroAbilityLookup.set(hero.name, { + ability1: { name: hero.ability1.name, impact: hero.ability1.impact }, + ability2: { name: hero.ability2.name, impact: hero.ability2.impact }, + }); +} + +function isHighImpact(impact: AbilityImpact): boolean { + return impact === "high" || impact === "critical"; +} + +const PRE_FIGHT_BUFFER = 5; +const CLEANUP_BUFFER = 2; + +function classifyPhase(abilityTime: number, fight: Fight): FightPhase | null { + const fightStart = fight.start; + const fightEnd = fight.end; + const duration = fightEnd - fightStart; + + if ( + abilityTime < fightStart - PRE_FIGHT_BUFFER || + abilityTime > fightEnd + CLEANUP_BUFFER + ) { + return null; + } + + if (abilityTime < fightStart) { + return "pre-fight"; + } + + if (abilityTime > fightEnd) { + return "cleanup"; + } + + if (duration < 4) { + return "mid"; + } + + const elapsed = abilityTime - fightStart; + const progress = elapsed / duration; + + if (progress <= 0.25) return "early"; + if (progress <= 0.75) return "mid"; + return "late"; +} + +function assignToFight( + abilityTime: number, + fights: Fight[] +): { fight: Fight; phase: FightPhase } | null { + let bestMatch: { fight: Fight; phase: FightPhase; distance: number } | null = + null; + + for (const fight of fights) { + const phase = classifyPhase(abilityTime, fight); + if (phase === null) continue; + + const fightCenter = (fight.start + fight.end) / 2; + const distance = Math.abs(abilityTime - fightCenter); + + if (!bestMatch || distance < bestMatch.distance) { + bestMatch = { fight, phase, distance }; + } + } + + return bestMatch ? { fight: bestMatch.fight, phase: bestMatch.phase } : null; +} + +function determineFightOutcome( + fight: Fight, + ourTeamName: string +): "win" | "loss" { + let ourKills = 0; + let enemyKills = 0; + + for (const event of fight.kills) { + if (event.event_type === ("mercy_rez" as string)) { + if (event.victim_team === ourTeamName) { + enemyKills = Math.max(0, enemyKills - 1); + } else { + ourKills = Math.max(0, ourKills - 1); + } + } else if (event.event_type === "kill") { + if (event.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + } + + return ourKills > enemyKills ? "win" : "loss"; +} + +type AbilityEvent = { + match_time: number; + player_team: string; + player_hero: string; + MapDataId: number | null; +}; + +function emptyPhaseStats(): AbilityPhaseStats { + return { fights: 0, wins: 0, losses: 0, winrate: 0 }; +} + +function emptyPhases(): Record { + return { + "pre-fight": emptyPhaseStats(), + early: emptyPhaseStats(), + mid: emptyPhaseStats(), + late: emptyPhaseStats(), + cleanup: emptyPhaseStats(), + }; +} + +const PHASE_ORDER: FightPhase[] = [ + "pre-fight", + "early", + "mid", + "late", + "cleanup", +]; + +const MIN_FIGHTS_FOR_DISPLAY = 3; +const OUTLIER_THRESHOLD_PP = 15; + +function processAbilityTimingAnalysis( + fights: Fight[], + ability1Events: AbilityEvent[], + ability2Events: AbilityEvent[], + ourTeamName: string +): AbilityTimingAnalysis { + if (fights.length === 0) { + return { rows: [], outliers: [] }; + } + + const fightOutcomes = new Map(); + for (const fight of fights) { + fightOutcomes.set(fight, determineFightOutcome(fight, ourTeamName)); + } + + type AccumKey = string; + const accum = new Map< + AccumKey, + { + heroName: string; + abilityName: string; + abilitySlot: 1 | 2; + impactRating: "high" | "critical"; + fightPhaseUsage: Map>; + } + >(); + + function processEvents(events: AbilityEvent[], slot: 1 | 2) { + for (const event of events) { + if (event.player_team !== ourTeamName) continue; + + const heroDef = heroAbilityLookup.get(event.player_hero); + if (!heroDef) continue; + + const abilityDef = slot === 1 ? heroDef.ability1 : heroDef.ability2; + if (!isHighImpact(abilityDef.impact)) continue; + + const match = assignToFight(event.match_time, fights); + if (!match) continue; + + const key = `${event.player_hero}|${slot}`; + if (!accum.has(key)) { + accum.set(key, { + heroName: event.player_hero, + abilityName: abilityDef.name, + abilitySlot: slot, + impactRating: abilityDef.impact as "high" | "critical", + fightPhaseUsage: new Map(), + }); + } + + const entry = accum.get(key)!; + if (!entry.fightPhaseUsage.has(match.fight)) { + entry.fightPhaseUsage.set(match.fight, new Set()); + } + entry.fightPhaseUsage.get(match.fight)!.add(match.phase); + } + } + + processEvents(ability1Events, 1); + processEvents(ability2Events, 2); + + const rows: AbilityTimingRow[] = []; + + for (const entry of accum.values()) { + const phases = emptyPhases(); + let totalWins = 0; + let totalFights = 0; + + for (const [fight, phasesUsed] of entry.fightPhaseUsage) { + const outcome = fightOutcomes.get(fight)!; + const won = outcome === "win"; + totalFights++; + if (won) totalWins++; + + for (const phase of phasesUsed) { + phases[phase].fights++; + if (won) phases[phase].wins++; + else phases[phase].losses++; + } + } + + for (const phase of PHASE_ORDER) { + const s = phases[phase]; + s.winrate = s.fights > 0 ? (s.wins / s.fights) * 100 : 0; + } + + if (totalFights === 0) continue; + + rows.push({ + heroName: entry.heroName, + abilityName: entry.abilityName, + abilitySlot: entry.abilitySlot, + impactRating: entry.impactRating, + phases, + overallWinrate: (totalWins / totalFights) * 100, + totalFights, + }); + } + + rows.sort((a, b) => { + if (a.impactRating !== b.impactRating) { + return a.impactRating === "critical" ? -1 : 1; + } + return b.totalFights - a.totalFights; + }); + + const outliers: AbilityTimingOutlier[] = []; + + for (const row of rows) { + let bestPhase: FightPhase = "mid"; + let bestWinrate = -1; + + for (const phase of PHASE_ORDER) { + const s = row.phases[phase]; + if (s.fights >= MIN_FIGHTS_FOR_DISPLAY && s.winrate > bestWinrate) { + bestWinrate = s.winrate; + bestPhase = phase; + } + } + + for (const phase of PHASE_ORDER) { + const s = row.phases[phase]; + if (s.fights < MIN_FIGHTS_FOR_DISPLAY) continue; + + const deviation = s.winrate - row.overallWinrate; + if (Math.abs(deviation) < OUTLIER_THRESHOLD_PP) continue; + + outliers.push({ + heroName: row.heroName, + abilityName: row.abilityName, + phase, + phaseWinrate: s.winrate, + overallWinrate: row.overallWinrate, + deviation, + bestPhase, + bestPhaseWinrate: bestWinrate, + type: deviation < 0 ? "negative" : "positive", + }); + } + } + + outliers.sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation)); + const topOutliers = outliers.slice(0, 3); + + return { rows, outliers: topOutliers }; +} + +export type ScrimAbilityTimingServiceInterface = { + readonly getScrimAbilityTiming: ( + scrimId: number, + teamId: number + ) => Effect.Effect; + + readonly getScrimFightTimelines: ( + scrimId: number, + teamId: number + ) => Effect.Effect; + + readonly getMapAbilityTiming: ( + mapId: number, + team1Name: string, + team2Name: string + ) => Effect.Effect; +}; + +export class ScrimAbilityTimingService extends Context.Tag( + "@app/data/scrim/ScrimAbilityTimingService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect< + ScrimAbilityTimingServiceInterface, + never, + TeamSharedDataService +> = Effect.gen(function* () { + const teamSharedData = yield* TeamSharedDataService; + + function getScrimAbilityTiming( + scrimId: number, + teamId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { scrimId, teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimId", scrimId); + yield* Effect.annotateCurrentSpan("teamId", teamId); + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { scrimId }, + orderBy: { id: "asc" }, + select: { id: true, mapData: { select: { id: true } } }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimAbilityTiming.fetchMaps", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.abilityTiming.fetchMaps", { + attributes: { scrimId }, + }) + ); + + if (maps.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_maps"; + yield* Metric.increment(scrimAbilityTimingSuccessTotal); + return { rows: [], outliers: [] }; + } + + const mapIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); + wideEvent.map_count = maps.length; + wideEvent.map_data_count = mapIds.length; + + const teamRosterArr = yield* teamSharedData.getTeamRoster(teamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getScrimAbilityTiming.fetchRoster", + cause: error, + }) + ) + ); + + const [allKills, allRezzes, allAbility1, allAbility2] = + yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { MapDataId: { in: mapIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.mercyRez.findMany({ + where: { MapDataId: { in: mapIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.ability1Used.findMany({ + where: { MapDataId: { in: mapIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: { in: mapIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimAbilityTiming.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.abilityTiming.fetchData", { + attributes: { scrimId, teamId, mapCount: mapIds.length }, + }) + ); + + const teamRoster = new Set(teamRosterArr); + + const dedupedKills = removeDuplicateRows(allKills); + const killEvents = [ + ...dedupedKills, + ...allRezzes.map(mercyRezToKillEvent), + ].sort((a, b) => a.match_time - b.match_time); + + const fights = groupEventsIntoFights(killEvents); + + if (fights.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_fights"; + yield* Metric.increment(scrimAbilityTimingSuccessTotal); + return { rows: [], outliers: [] }; + } + + let ourTeamName = ""; + for (const kill of dedupedKills) { + if (teamRoster.has(kill.attacker_name)) { + ourTeamName = kill.attacker_team; + break; + } + if (kill.victim_name && teamRoster.has(kill.victim_name)) { + ourTeamName = kill.victim_team; + break; + } + } + + if (!ourTeamName) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_team_name"; + yield* Metric.increment(scrimAbilityTimingSuccessTotal); + return { rows: [], outliers: [] }; + } + + wideEvent.fight_count = fights.length; + wideEvent.our_team_name = ourTeamName; + + const result = processAbilityTimingAnalysis( + fights, + allAbility1, + allAbility2, + ourTeamName + ); + + wideEvent.row_count = result.rows.length; + wideEvent.outlier_count = result.outliers.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimAbilityTimingSuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(scrimAbilityTimingErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getScrimAbilityTiming") + : Effect.logInfo("scrim.getScrimAbilityTiming"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimAbilityTimingDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getScrimAbilityTiming") + ); + } + + function getScrimFightTimelines( + scrimId: number, + teamId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { scrimId, teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimId", scrimId); + yield* Effect.annotateCurrentSpan("teamId", teamId); + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { scrimId }, + orderBy: { id: "asc" }, + select: { id: true, mapData: { select: { id: true } } }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimFightTimelines.fetchMaps", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.fightTimelines.fetchMaps", { + attributes: { scrimId }, + }) + ); + + if (maps.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_maps"; + yield* Metric.increment(scrimFightTimelinesSuccessTotal); + return { fights: [], ourTeamName: "" }; + } + + const mapIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); + wideEvent.map_count = maps.length; + + const teamRosterArr = yield* teamSharedData.getTeamRoster(teamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getScrimFightTimelines.fetchRoster", + cause: error, + }) + ) + ); + + const [allKills, allRezzes, allAbility1, allAbility2] = + yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { MapDataId: { in: mapIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.mercyRez.findMany({ + where: { MapDataId: { in: mapIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.ability1Used.findMany({ + where: { MapDataId: { in: mapIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + player_name: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: { in: mapIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + player_name: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimFightTimelines.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.fightTimelines.fetchData", { + attributes: { scrimId, teamId, mapCount: mapIds.length }, + }) + ); + + const teamRoster = new Set(teamRosterArr); + + const dedupedKills = removeDuplicateRows(allKills); + const killEvents = [ + ...dedupedKills, + ...allRezzes.map(mercyRezToKillEvent), + ].sort((a, b) => a.match_time - b.match_time); + + const fights = groupEventsIntoFights(killEvents); + + if (fights.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_fights"; + yield* Metric.increment(scrimFightTimelinesSuccessTotal); + return { fights: [], ourTeamName: "" }; + } + + let ourTeamName = ""; + for (const kill of dedupedKills) { + if (teamRoster.has(kill.attacker_name)) { + ourTeamName = kill.attacker_team; + break; + } + if (kill.victim_name && teamRoster.has(kill.victim_name)) { + ourTeamName = kill.victim_team; + break; + } + } + + if (!ourTeamName) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_team_name"; + yield* Metric.increment(scrimFightTimelinesSuccessTotal); + return { fights: [], ourTeamName: "" }; + } + + wideEvent.fight_count = fights.length; + + const timelines: FightTimeline[] = fights.map((fight, idx) => { + const outcome = determineFightOutcome(fight, ourTeamName); + + const kills: FightKillEvent[] = fight.kills + .filter((k) => k.event_type === "kill") + .map((k) => ({ + time: Math.round(k.match_time * 10) / 10, + attacker: k.attacker_name, + attackerHero: k.attacker_hero, + victim: k.victim_name, + victimHero: k.victim_hero, + attackerSide: + k.attacker_team === ourTeamName + ? ("ours" as const) + : ("enemy" as const), + })); + + const windowStart = fight.start - PRE_FIGHT_BUFFER; + const windowEnd = fight.end + CLEANUP_BUFFER; + + function mapAbilityEvents( + events: { + match_time: number; + player_team: string; + player_hero: string; + player_name: string; + MapDataId: number | null; + }[], + slot: 1 | 2 + ): FightAbilityEvent[] { + return events + .filter( + (e) => e.match_time >= windowStart && e.match_time <= windowEnd + ) + .map((e) => { + const heroDef = heroAbilityLookup.get(e.player_hero); + const abilityDef = + slot === 1 ? heroDef?.ability1 : heroDef?.ability2; + const phase = classifyPhase(e.match_time, fight); + + return { + time: Math.round(e.match_time * 10) / 10, + hero: e.player_hero, + ability: abilityDef?.name ?? `ability${slot}`, + abilitySlot: slot, + team: + e.player_team === ourTeamName + ? ("ours" as const) + : ("enemy" as const), + phase: phase ?? ("mid" as FightPhase), + }; + }); + } + + const abilityUses = [ + ...mapAbilityEvents(allAbility1, 1), + ...mapAbilityEvents(allAbility2, 2), + ].sort((a, b) => a.time - b.time); + + return { + fightNumber: idx + 1, + startTime: Math.round(fight.start * 10) / 10, + endTime: Math.round(fight.end * 10) / 10, + duration: Math.round((fight.end - fight.start) * 10) / 10, + outcome, + kills, + abilityUses, + }; + }); + + wideEvent.timeline_count = timelines.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimFightTimelinesSuccessTotal); + return { fights: timelines, ourTeamName }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(scrimFightTimelinesErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getScrimFightTimelines") + : Effect.logInfo("scrim.getScrimFightTimelines"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimFightTimelinesDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getScrimFightTimelines") + ); + } + + function getMapAbilityTiming( + mapId: number, + team1Name: string, + team2Name: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + mapId, + team1Name, + team2Name, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + yield* Effect.annotateCurrentSpan("team1Name", team1Name); + yield* Effect.annotateCurrentSpan("team2Name", team2Name); + const empty: MapAbilityTimingAnalysis = { + team1: { rows: [], outliers: [] }, + team2: { rows: [], outliers: [] }, + }; + + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new ScrimQueryError({ + operation: "getMapAbilityTiming.resolveMapDataId", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.mapAbilityTiming.resolveMapDataId", { + attributes: { mapId }, + }) + ); + + wideEvent.mapDataId = mapDataId; + + const [allKills, allRezzes, allAbility1, allAbility2] = + yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { match_time: "asc" }, + }), + prisma.mercyRez.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { match_time: "asc" }, + }), + prisma.ability1Used.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new ScrimQueryError({ + operation: "getMapAbilityTiming.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.mapAbilityTiming.fetchData", { + attributes: { mapId, mapDataId }, + }) + ); + + if (allAbility1.length === 0 && allAbility2.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_ability_data"; + yield* Metric.increment(scrimMapAbilityTimingSuccessTotal); + return empty; + } + + const dedupedKills = removeDuplicateRows(allKills); + const killEvents = [ + ...dedupedKills, + ...allRezzes.map(mercyRezToKillEvent), + ].sort((a, b) => a.match_time - b.match_time); + + const fights = groupEventsIntoFights(killEvents); + + if (fights.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_fights"; + yield* Metric.increment(scrimMapAbilityTimingSuccessTotal); + return empty; + } + + wideEvent.fight_count = fights.length; + + const team1Analysis = processAbilityTimingAnalysis( + fights, + allAbility1, + allAbility2, + team1Name + ); + const team2Analysis = processAbilityTimingAnalysis( + fights, + allAbility1, + allAbility2, + team2Name + ); + + wideEvent.team1_rows = team1Analysis.rows.length; + wideEvent.team2_rows = team2Analysis.rows.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimMapAbilityTimingSuccessTotal); + return { team1: team1Analysis, team2: team2Analysis }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scrimMapAbilityTimingErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getMapAbilityTiming") + : Effect.logInfo("scrim.getMapAbilityTiming"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimMapAbilityTimingDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getMapAbilityTiming") + ); + } + + function abilityTimingCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + + const abilityTimingCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimId, teamId } = JSON.parse(key) as { + scrimId: number; + teamId: number; + }; + return getScrimAbilityTiming(scrimId, teamId).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function fightTimelinesCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + + const fightTimelinesCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimId, teamId } = JSON.parse(key) as { + scrimId: number; + teamId: number; + }; + return getScrimFightTimelines(scrimId, teamId).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function mapAbilityTimingCacheKeyOf( + mapId: number, + team1Name: string, + team2Name: string + ) { return JSON.stringify({ mapId, team1Name, team2Name }); } + + const mapAbilityTimingCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { mapId, team1Name, team2Name } = JSON.parse(key) as { + mapId: number; + team1Name: string; + team2Name: string; + }; + return getMapAbilityTiming(mapId, team1Name, team2Name).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + return { + getScrimAbilityTiming: (scrimId: number, teamId: number) => + abilityTimingCache + .get(abilityTimingCacheKeyOf(scrimId, teamId)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getScrimFightTimelines: (scrimId: number, teamId: number) => + fightTimelinesCache + .get(fightTimelinesCacheKeyOf(scrimId, teamId)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getMapAbilityTiming: ( + mapId: number, + team1Name: string, + team2Name: string + ) => + mapAbilityTimingCache + .get(mapAbilityTimingCacheKeyOf(mapId, team1Name, team2Name)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + } satisfies ScrimAbilityTimingServiceInterface; +}); + +export const ScrimAbilityTimingServiceLive = Layer.effect( + ScrimAbilityTimingService, + make +).pipe( + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/scrim/errors.ts b/src/data/scrim/errors.ts new file mode 100644 index 000000000..ee1b30dd0 --- /dev/null +++ b/src/data/scrim/errors.ts @@ -0,0 +1,24 @@ +import { Schema as S } from "effect"; + +export class ScrimNotFoundError extends S.TaggedError()( + "ScrimNotFoundError", + { + scrimId: S.Number, + } +) { + get message(): string { + return `Scrim not found: ${this.scrimId}`; + } +} + +export class ScrimQueryError extends S.TaggedError()( + "ScrimQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Scrim query failed: ${this.operation}`; + } +} diff --git a/src/data/scrim/index.ts b/src/data/scrim/index.ts new file mode 100644 index 000000000..27f12d08b --- /dev/null +++ b/src/data/scrim/index.ts @@ -0,0 +1,31 @@ +export { ScrimService, ScrimServiceLive } from "./scrim-service"; +export type { ScrimServiceInterface, Winrate } from "./scrim-service"; + +export { + ScrimOverviewService, + ScrimOverviewServiceLive, +} from "./overview-service"; +export type { ScrimOverviewServiceInterface } from "./overview-service"; + +export { + ScrimOpponentService, + ScrimOpponentServiceLive, +} from "./opponent-service"; +export type { ScrimOpponentServiceInterface } from "./opponent-service"; + +export { + ScrimAbilityTimingService, + ScrimAbilityTimingServiceLive, +} from "./ability-timing-service"; +export type { ScrimAbilityTimingServiceInterface } from "./ability-timing-service"; + +export { ScrimNotFoundError, ScrimQueryError } from "./errors"; + +export { + ScrimIdSchema, + MapIdSchema, + TeamIdSchema, + UserIdSchema, + PlayerNameSchema, + OpponentAbbrSchema, +} from "./types"; diff --git a/src/data/scrim/metrics.ts b/src/data/scrim/metrics.ts new file mode 100644 index 000000000..72e6e9e34 --- /dev/null +++ b/src/data/scrim/metrics.ts @@ -0,0 +1,355 @@ +import { Metric, MetricBoundaries } from "effect"; + +// scrim-service + +export const scrimGetScrimSuccessTotal = Metric.counter( + "scrim.get_scrim.query.success", + { + description: "Total successful getScrim queries", + incremental: true, + } +); + +export const scrimGetScrimErrorTotal = Metric.counter( + "scrim.get_scrim.query.error", + { + description: "Total getScrim query failures", + incremental: true, + } +); + +export const scrimGetScrimDuration = Metric.histogram( + "scrim.get_scrim.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getScrim query duration in milliseconds" +); + +export const scrimGetUserViewableScrimsSuccessTotal = Metric.counter( + "scrim.get_user_viewable_scrims.query.success", + { + description: "Total successful getUserViewableScrims queries", + incremental: true, + } +); + +export const scrimGetUserViewableScrimsErrorTotal = Metric.counter( + "scrim.get_user_viewable_scrims.query.error", + { + description: "Total getUserViewableScrims query failures", + incremental: true, + } +); + +export const scrimGetUserViewableScrimsDuration = Metric.histogram( + "scrim.get_user_viewable_scrims.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getUserViewableScrims query duration in milliseconds" +); + +export const scrimGetFinalRoundStatsSuccessTotal = Metric.counter( + "scrim.get_final_round_stats.query.success", + { + description: "Total successful getFinalRoundStats queries", + incremental: true, + } +); + +export const scrimGetFinalRoundStatsErrorTotal = Metric.counter( + "scrim.get_final_round_stats.query.error", + { + description: "Total getFinalRoundStats query failures", + incremental: true, + } +); + +export const scrimGetFinalRoundStatsDuration = Metric.histogram( + "scrim.get_final_round_stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getFinalRoundStats query duration in milliseconds" +); + +export const scrimGetFinalRoundStatsForPlayerSuccessTotal = Metric.counter( + "scrim.get_final_round_stats_for_player.query.success", + { + description: "Total successful getFinalRoundStatsForPlayer queries", + incremental: true, + } +); + +export const scrimGetFinalRoundStatsForPlayerErrorTotal = Metric.counter( + "scrim.get_final_round_stats_for_player.query.error", + { + description: "Total getFinalRoundStatsForPlayer query failures", + incremental: true, + } +); + +export const scrimGetFinalRoundStatsForPlayerDuration = Metric.histogram( + "scrim.get_final_round_stats_for_player.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getFinalRoundStatsForPlayer query duration in milliseconds" +); + +// getAllStatsForPlayer +export const scrimGetAllStatsForPlayerSuccessTotal = Metric.counter( + "scrim.get_all_stats_for_player.query.success", + { + description: "Total successful getAllStatsForPlayer queries", + incremental: true, + } +); + +export const scrimGetAllStatsForPlayerErrorTotal = Metric.counter( + "scrim.get_all_stats_for_player.query.error", + { + description: "Total getAllStatsForPlayer query failures", + incremental: true, + } +); + +export const scrimGetAllStatsForPlayerDuration = Metric.histogram( + "scrim.get_all_stats_for_player.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getAllStatsForPlayer query duration in milliseconds" +); + +// getAllKillsForPlayer +export const scrimGetAllKillsForPlayerSuccessTotal = Metric.counter( + "scrim.get_all_kills_for_player.query.success", + { + description: "Total successful getAllKillsForPlayer queries", + incremental: true, + } +); + +export const scrimGetAllKillsForPlayerErrorTotal = Metric.counter( + "scrim.get_all_kills_for_player.query.error", + { + description: "Total getAllKillsForPlayer query failures", + incremental: true, + } +); + +export const scrimGetAllKillsForPlayerDuration = Metric.histogram( + "scrim.get_all_kills_for_player.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getAllKillsForPlayer query duration in milliseconds" +); + +// getAllDeathsForPlayer +export const scrimGetAllDeathsForPlayerSuccessTotal = Metric.counter( + "scrim.get_all_deaths_for_player.query.success", + { + description: "Total successful getAllDeathsForPlayer queries", + incremental: true, + } +); + +export const scrimGetAllDeathsForPlayerErrorTotal = Metric.counter( + "scrim.get_all_deaths_for_player.query.error", + { + description: "Total getAllDeathsForPlayer query failures", + incremental: true, + } +); + +export const scrimGetAllDeathsForPlayerDuration = Metric.histogram( + "scrim.get_all_deaths_for_player.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getAllDeathsForPlayer query duration in milliseconds" +); + +// getAllMapWinratesForPlayer +export const scrimGetAllMapWinratesForPlayerSuccessTotal = Metric.counter( + "scrim.get_all_map_winrates_for_player.query.success", + { + description: "Total successful getAllMapWinratesForPlayer queries", + incremental: true, + } +); + +export const scrimGetAllMapWinratesForPlayerErrorTotal = Metric.counter( + "scrim.get_all_map_winrates_for_player.query.error", + { + description: "Total getAllMapWinratesForPlayer query failures", + incremental: true, + } +); + +export const scrimGetAllMapWinratesForPlayerDuration = Metric.histogram( + "scrim.get_all_map_winrates_for_player.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of getAllMapWinratesForPlayer query duration in milliseconds" +); + +// overview-service + +export const scrimOverviewSuccessTotal = Metric.counter( + "scrim.overview.query.success", + { + description: "Total successful scrim overview queries", + incremental: true, + } +); + +export const scrimOverviewErrorTotal = Metric.counter( + "scrim.overview.query.error", + { + description: "Total scrim overview query failures", + incremental: true, + } +); + +export const scrimOverviewDuration = Metric.histogram( + "scrim.overview.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scrim overview query duration in milliseconds" +); + +// opponent-service + +export const scrimOpponentMapResultsSuccessTotal = Metric.counter( + "scrim.opponent.map_results.query.success", + { + description: "Total successful opponent scrim map results queries", + incremental: true, + } +); + +export const scrimOpponentMapResultsErrorTotal = Metric.counter( + "scrim.opponent.map_results.query.error", + { + description: "Total opponent scrim map results query failures", + incremental: true, + } +); + +export const scrimOpponentMapResultsDuration = Metric.histogram( + "scrim.opponent.map_results.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of opponent scrim map results query duration in milliseconds" +); + +export const scrimOpponentHeroBansSuccessTotal = Metric.counter( + "scrim.opponent.hero_bans.query.success", + { + description: "Total successful opponent scrim hero bans queries", + incremental: true, + } +); + +export const scrimOpponentHeroBansErrorTotal = Metric.counter( + "scrim.opponent.hero_bans.query.error", + { + description: "Total opponent scrim hero bans query failures", + incremental: true, + } +); + +export const scrimOpponentHeroBansDuration = Metric.histogram( + "scrim.opponent.hero_bans.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of opponent scrim hero bans query duration in milliseconds" +); + +export const scrimOpponentPlayerStatsSuccessTotal = Metric.counter( + "scrim.opponent.player_stats.query.success", + { + description: "Total successful opponent scrim player stats queries", + incremental: true, + } +); + +export const scrimOpponentPlayerStatsErrorTotal = Metric.counter( + "scrim.opponent.player_stats.query.error", + { + description: "Total opponent scrim player stats query failures", + incremental: true, + } +); + +export const scrimOpponentPlayerStatsDuration = Metric.histogram( + "scrim.opponent.player_stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of opponent scrim player stats query duration in milliseconds" +); + +// ability-timing-service + +export const scrimAbilityTimingSuccessTotal = Metric.counter( + "scrim.ability_timing.query.success", + { + description: "Total successful scrim ability timing queries", + incremental: true, + } +); + +export const scrimAbilityTimingErrorTotal = Metric.counter( + "scrim.ability_timing.query.error", + { + description: "Total scrim ability timing query failures", + incremental: true, + } +); + +export const scrimAbilityTimingDuration = Metric.histogram( + "scrim.ability_timing.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scrim ability timing query duration in milliseconds" +); + +export const scrimFightTimelinesSuccessTotal = Metric.counter( + "scrim.fight_timelines.query.success", + { + description: "Total successful scrim fight timelines queries", + incremental: true, + } +); + +export const scrimFightTimelinesErrorTotal = Metric.counter( + "scrim.fight_timelines.query.error", + { + description: "Total scrim fight timelines query failures", + incremental: true, + } +); + +export const scrimFightTimelinesDuration = Metric.histogram( + "scrim.fight_timelines.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scrim fight timelines query duration in milliseconds" +); + +export const scrimMapAbilityTimingSuccessTotal = Metric.counter( + "scrim.map_ability_timing.query.success", + { + description: "Total successful map ability timing queries", + incremental: true, + } +); + +export const scrimMapAbilityTimingErrorTotal = Metric.counter( + "scrim.map_ability_timing.query.error", + { + description: "Total map ability timing query failures", + incremental: true, + } +); + +export const scrimMapAbilityTimingDuration = Metric.histogram( + "scrim.map_ability_timing.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of map ability timing query duration in milliseconds" +); + +// cache metrics + +export const scrimCacheRequestTotal = Metric.counter("scrim.cache.request", { + description: "Total scrim data cache requests", + incremental: true, +}); + +export const scrimCacheMissTotal = Metric.counter("scrim.cache.miss", { + description: "Total scrim data cache misses (triggered lookup)", + incremental: true, +}); diff --git a/src/data/scrim/opponent-service.ts b/src/data/scrim/opponent-service.ts new file mode 100644 index 000000000..9759e867d --- /dev/null +++ b/src/data/scrim/opponent-service.ts @@ -0,0 +1,853 @@ +import { findTeamNameForMapInMemory } from "@/data/team/shared-core"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import type { + MapType, + MatchStart, + ObjectiveCaptured, + PayloadProgress, + PointProgress, + RoundEnd, +} from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScrimQueryError } from "./errors"; +import { + scrimCacheMissTotal, + scrimCacheRequestTotal, + scrimOpponentHeroBansDuration, + scrimOpponentHeroBansErrorTotal, + scrimOpponentHeroBansSuccessTotal, + scrimOpponentMapResultsDuration, + scrimOpponentMapResultsErrorTotal, + scrimOpponentMapResultsSuccessTotal, + scrimOpponentPlayerStatsDuration, + scrimOpponentPlayerStatsErrorTotal, + scrimOpponentPlayerStatsSuccessTotal, +} from "./metrics"; +import type { ScrimHeroBan, ScrimMapResult, ScrimPlayerStat } from "./types"; + +export type { ScrimHeroBan, ScrimMapResult, ScrimPlayerStat } from "./types"; + +type HeroRole = "Tank" | "Damage" | "Support"; + +type MapDataRow = { + id: number; + mapName: string; + mapType: MapType | null; + scrimDate: Date; +}; + +type ScrimPlayerStatRow = { + player_name: string; + player_team: string; + MapDataId: number | null; +}; + +type TaggedScrimData = { + mapDataRows: MapDataRow[]; + allPlayerStats: ScrimPlayerStatRow[]; + teamRosterSet: Set; + matchStartsByMapDataId: Map; + finalRoundsByMapDataId: Map; + capturesByTeam: Map< + number, + { team1: ObjectiveCaptured[]; team2: ObjectiveCaptured[] } + >; + payloadProgressByTeam: Map< + number, + { team1: PayloadProgress[]; team2: PayloadProgress[] } + >; + pointProgressByTeam: Map< + number, + { team1: PointProgress[]; team2: PointProgress[] } + >; + heroBansByMapDataId: Map; + fullPlayerStatsByMapDataId: Map< + number, + { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + eliminations: number; + deaths: number; + }[] + >; +}; + +function resolveOpponentTeamString( + mapDataId: number, + allPlayerStats: ScrimPlayerStatRow[], + teamRosterSet: Set, + matchStart: MatchStart | undefined +): string | null { + const userTeamString = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!userTeamString || !matchStart) return null; + + const team1 = matchStart.team_1_name; + const team2 = matchStart.team_2_name; + if (userTeamString === team1) return team2; + if (userTeamString === team2) return team1; + return null; +} + +async function fetchTaggedScrimDataRaw( + userTeamId: number, + opponentAbbr: string, + teamRoster: string[] +): Promise { + const teamRosterSet = new Set(teamRoster); + + const scrims = await prisma.scrim.findMany({ + where: { teamId: userTeamId, opponentTeamAbbr: opponentAbbr }, + select: { + date: true, + maps: { + select: { + id: true, + name: true, + mapData: { select: { id: true } }, + }, + }, + }, + }); + + if (scrims.length === 0) { + return { + mapDataRows: [], + allPlayerStats: [], + teamRosterSet, + matchStartsByMapDataId: new Map(), + finalRoundsByMapDataId: new Map(), + capturesByTeam: new Map(), + payloadProgressByTeam: new Map(), + pointProgressByTeam: new Map(), + heroBansByMapDataId: new Map(), + fullPlayerStatsByMapDataId: new Map(), + }; + } + + const mapDataRows: MapDataRow[] = []; + const allMapDataIds: number[] = []; + + for (const scrim of scrims) { + for (const map of scrim.maps) { + for (const mapData of map.mapData) { + allMapDataIds.push(mapData.id); + mapDataRows.push({ + id: mapData.id, + mapName: map.name, + mapType: + mapNameToMapTypeMapping[ + map.name as keyof typeof mapNameToMapTypeMapping + ] ?? null, + scrimDate: scrim.date, + }); + } + } + } + + if (allMapDataIds.length === 0) { + return { + mapDataRows: [], + allPlayerStats: [], + teamRosterSet, + matchStartsByMapDataId: new Map(), + finalRoundsByMapDataId: new Map(), + capturesByTeam: new Map(), + payloadProgressByTeam: new Map(), + pointProgressByTeam: new Map(), + heroBansByMapDataId: new Map(), + fullPlayerStatsByMapDataId: new Map(), + }; + } + + const [ + allPlayerStatsFull, + matchStarts, + roundEnds, + captures, + payloadProgressRows, + pointProgressRows, + heroBans, + ] = await Promise.all([ + prisma.playerStat.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + eliminations: true, + deaths: true, + MapDataId: true, + }, + }), + prisma.matchStart.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + orderBy: { round_number: "desc" }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.heroBan.findMany({ + where: { MapDataId: { in: allMapDataIds } }, + select: { MapDataId: true, team: true, hero: true }, + }), + ]); + + const allPlayerStats: ScrimPlayerStatRow[] = allPlayerStatsFull.map((s) => ({ + player_name: s.player_name, + player_team: s.player_team, + MapDataId: s.MapDataId, + })); + + const matchStartsByMapDataId = new Map(); + for (const ms of matchStarts) { + if (ms.MapDataId) matchStartsByMapDataId.set(ms.MapDataId, ms); + } + + const finalRoundsByMapDataId = new Map(); + for (const round of roundEnds) { + if (!round.MapDataId) continue; + const existing = finalRoundsByMapDataId.get(round.MapDataId); + if (!existing || round.round_number > existing.round_number) { + finalRoundsByMapDataId.set(round.MapDataId, round); + } + } + + const capturesByTeam = new Map< + number, + { team1: ObjectiveCaptured[]; team2: ObjectiveCaptured[] } + >(); + for (const capture of captures) { + if (!capture.MapDataId) continue; + const ms = matchStartsByMapDataId.get(capture.MapDataId); + if (!ms) continue; + let entry = capturesByTeam.get(capture.MapDataId); + if (!entry) { + entry = { team1: [], team2: [] }; + capturesByTeam.set(capture.MapDataId, entry); + } + if (capture.capturing_team === ms.team_1_name) { + entry.team1.push(capture); + } else if (capture.capturing_team === ms.team_2_name) { + entry.team2.push(capture); + } + } + + const payloadProgressByTeam = new Map< + number, + { team1: PayloadProgress[]; team2: PayloadProgress[] } + >(); + for (const progressRow of payloadProgressRows) { + if (!progressRow.MapDataId) continue; + const ms = matchStartsByMapDataId.get(progressRow.MapDataId); + if (!ms) continue; + let entry = payloadProgressByTeam.get(progressRow.MapDataId); + if (!entry) { + entry = { team1: [], team2: [] }; + payloadProgressByTeam.set(progressRow.MapDataId, entry); + } + if (progressRow.capturing_team === ms.team_1_name) { + entry.team1.push(progressRow); + } else if (progressRow.capturing_team === ms.team_2_name) { + entry.team2.push(progressRow); + } + } + + const pointProgressByTeam = new Map< + number, + { team1: PointProgress[]; team2: PointProgress[] } + >(); + for (const progressRow of pointProgressRows) { + if (!progressRow.MapDataId) continue; + const ms = matchStartsByMapDataId.get(progressRow.MapDataId); + if (!ms) continue; + let entry = pointProgressByTeam.get(progressRow.MapDataId); + if (!entry) { + entry = { team1: [], team2: [] }; + pointProgressByTeam.set(progressRow.MapDataId, entry); + } + if (progressRow.capturing_team === ms.team_1_name) { + entry.team1.push(progressRow); + } else if (progressRow.capturing_team === ms.team_2_name) { + entry.team2.push(progressRow); + } + } + + const heroBansByMapDataId = new Map< + number, + { team: string; hero: string }[] + >(); + for (const ban of heroBans) { + if (!ban.MapDataId) continue; + let entry = heroBansByMapDataId.get(ban.MapDataId); + if (!entry) { + entry = []; + heroBansByMapDataId.set(ban.MapDataId, entry); + } + entry.push({ team: ban.team, hero: ban.hero }); + } + + const fullPlayerStatsByMapDataId = new Map< + number, + { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + eliminations: number; + deaths: number; + }[] + >(); + for (const stat of allPlayerStatsFull) { + if (!stat.MapDataId) continue; + let entry = fullPlayerStatsByMapDataId.get(stat.MapDataId); + if (!entry) { + entry = []; + fullPlayerStatsByMapDataId.set(stat.MapDataId, entry); + } + entry.push(stat); + } + + return { + mapDataRows, + allPlayerStats, + teamRosterSet, + matchStartsByMapDataId, + finalRoundsByMapDataId, + capturesByTeam, + payloadProgressByTeam, + pointProgressByTeam, + heroBansByMapDataId, + fullPlayerStatsByMapDataId, + }; +} + +export type ScrimOpponentServiceInterface = { + readonly getOpponentScrimMapResults: ( + userTeamId: number, + opponentAbbr: string + ) => Effect.Effect; + + readonly getOpponentScrimHeroBans: ( + userTeamId: number, + opponentAbbr: string + ) => Effect.Effect; + + readonly getOpponentScrimPlayerStats: ( + userTeamId: number, + opponentAbbr: string + ) => Effect.Effect; +}; + +export class ScrimOpponentService extends Context.Tag( + "@app/data/scrim/ScrimOpponentService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect< + ScrimOpponentServiceInterface, + never, + TeamSharedDataService +> = Effect.gen(function* () { + const teamSharedData = yield* TeamSharedDataService; + + function getOpponentScrimMapResults( + userTeamId: number, + opponentAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { userTeamId, opponentAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userTeamId", userTeamId); + yield* Effect.annotateCurrentSpan("opponentAbbr", opponentAbbr); + + const teamRoster = yield* teamSharedData.getTeamRoster(userTeamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getOpponentScrimMapResults.fetchRoster", + cause: error, + }) + ) + ); + + const data = yield* Effect.tryPromise({ + try: () => + fetchTaggedScrimDataRaw(userTeamId, opponentAbbr, teamRoster), + catch: (error) => + new ScrimQueryError({ + operation: "getOpponentScrimMapResults.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.opponent.mapResults.fetchData", { + attributes: { userTeamId, opponentAbbr }, + }) + ); + + const { + mapDataRows, + allPlayerStats, + teamRosterSet, + matchStartsByMapDataId, + finalRoundsByMapDataId, + capturesByTeam, + payloadProgressByTeam, + pointProgressByTeam, + } = data; + + const results: ScrimMapResult[] = []; + + for (const row of mapDataRows) { + const matchStart = matchStartsByMapDataId.get(row.id); + const userTeamString = findTeamNameForMapInMemory( + row.id, + allPlayerStats, + teamRosterSet + ); + if (!userTeamString || !matchStart) continue; + + const finalRound = finalRoundsByMapDataId.get(row.id) ?? null; + const captureEntry = capturesByTeam.get(row.id); + const payloadProgressEntry = payloadProgressByTeam.get(row.id); + const pointProgressEntry = pointProgressByTeam.get(row.id); + const winner = calculateWinner({ + matchDetails: matchStart, + finalRound, + team1Captures: captureEntry?.team1 ?? [], + team2Captures: captureEntry?.team2 ?? [], + team1PayloadProgress: payloadProgressEntry?.team1 ?? [], + team2PayloadProgress: payloadProgressEntry?.team2 ?? [], + team1PointProgress: pointProgressEntry?.team1 ?? [], + team2PointProgress: pointProgressEntry?.team2 ?? [], + }); + + if (winner === "N/A") continue; + + const mapType = matchStart.map_type ?? row.mapType; + if (!mapType) continue; + + results.push({ + mapName: row.mapName, + mapType, + scrimDate: row.scrimDate, + opponentWon: winner !== userTeamString, + source: "scrim", + }); + } + + wideEvent.result_count = results.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimOpponentMapResultsSuccessTotal); + return results; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scrimOpponentMapResultsErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getOpponentScrimMapResults") + : Effect.logInfo("scrim.getOpponentScrimMapResults"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimOpponentMapResultsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getOpponentScrimMapResults") + ); + } + + function getOpponentScrimHeroBans( + userTeamId: number, + opponentAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { userTeamId, opponentAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userTeamId", userTeamId); + yield* Effect.annotateCurrentSpan("opponentAbbr", opponentAbbr); + + const teamRoster = yield* teamSharedData.getTeamRoster(userTeamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getOpponentScrimHeroBans.fetchRoster", + cause: error, + }) + ) + ); + + const data = yield* Effect.tryPromise({ + try: () => + fetchTaggedScrimDataRaw(userTeamId, opponentAbbr, teamRoster), + catch: (error) => + new ScrimQueryError({ + operation: "getOpponentScrimHeroBans.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.opponent.heroBans.fetchData", { + attributes: { userTeamId, opponentAbbr }, + }) + ); + + const { + mapDataRows, + allPlayerStats, + teamRosterSet, + matchStartsByMapDataId, + finalRoundsByMapDataId, + capturesByTeam, + payloadProgressByTeam, + pointProgressByTeam, + heroBansByMapDataId, + } = data; + + const results: ScrimHeroBan[] = []; + + for (const row of mapDataRows) { + const matchStart = matchStartsByMapDataId.get(row.id); + const userTeamString = findTeamNameForMapInMemory( + row.id, + allPlayerStats, + teamRosterSet + ); + if (!userTeamString || !matchStart) continue; + + const opponentTeamString = resolveOpponentTeamString( + row.id, + allPlayerStats, + teamRosterSet, + matchStart + ); + if (!opponentTeamString) continue; + + const finalRound = finalRoundsByMapDataId.get(row.id) ?? null; + const captureEntry = capturesByTeam.get(row.id); + const payloadProgressEntry = payloadProgressByTeam.get(row.id); + const pointProgressEntry = pointProgressByTeam.get(row.id); + const winner = calculateWinner({ + matchDetails: matchStart, + finalRound, + team1Captures: captureEntry?.team1 ?? [], + team2Captures: captureEntry?.team2 ?? [], + team1PayloadProgress: payloadProgressEntry?.team1 ?? [], + team2PayloadProgress: payloadProgressEntry?.team2 ?? [], + team1PointProgress: pointProgressEntry?.team1 ?? [], + team2PointProgress: pointProgressEntry?.team2 ?? [], + }); + + if (winner === "N/A") continue; + + const mapType = matchStart.map_type ?? row.mapType; + if (!mapType) continue; + + const bans = heroBansByMapDataId.get(row.id) ?? []; + const opponentBans = bans + .filter((b) => b.team === opponentTeamString) + .map((b) => b.hero); + + results.push({ + mapName: row.mapName, + mapType, + scrimDate: row.scrimDate, + opponentWon: winner !== userTeamString, + opponentBans, + source: "scrim", + }); + } + + wideEvent.result_count = results.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimOpponentHeroBansSuccessTotal); + return results; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scrimOpponentHeroBansErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getOpponentScrimHeroBans") + : Effect.logInfo("scrim.getOpponentScrimHeroBans"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimOpponentHeroBansDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getOpponentScrimHeroBans") + ); + } + + function getOpponentScrimPlayerStats( + userTeamId: number, + opponentAbbr: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { userTeamId, opponentAbbr }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userTeamId", userTeamId); + yield* Effect.annotateCurrentSpan("opponentAbbr", opponentAbbr); + + const teamRoster = yield* teamSharedData.getTeamRoster(userTeamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getOpponentScrimPlayerStats.fetchRoster", + cause: error, + }) + ) + ); + + const data = yield* Effect.tryPromise({ + try: () => + fetchTaggedScrimDataRaw(userTeamId, opponentAbbr, teamRoster), + catch: (error) => + new ScrimQueryError({ + operation: "getOpponentScrimPlayerStats.fetchData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.opponent.playerStats.fetchData", { + attributes: { userTeamId, opponentAbbr }, + }) + ); + + const { + mapDataRows, + allPlayerStats, + teamRosterSet, + matchStartsByMapDataId, + fullPlayerStatsByMapDataId, + } = data; + + const aggregated = new Map< + string, + { + hero: string; + role: HeroRole; + timePlayed: number; + eliminations: number; + deaths: number; + } + >(); + + for (const row of mapDataRows) { + const matchStart = matchStartsByMapDataId.get(row.id); + const userTeamString = findTeamNameForMapInMemory( + row.id, + allPlayerStats, + teamRosterSet + ); + if (!userTeamString || !matchStart) continue; + + const opponentTeamString = resolveOpponentTeamString( + row.id, + allPlayerStats, + teamRosterSet, + matchStart + ); + if (!opponentTeamString) continue; + + const stats = fullPlayerStatsByMapDataId.get(row.id) ?? []; + for (const stat of stats) { + if (stat.player_team !== opponentTeamString) continue; + + const key = `${stat.player_name}__${stat.player_hero}`; + const role = + heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; + const existing = aggregated.get(key); + if (existing) { + existing.timePlayed += stat.hero_time_played; + existing.eliminations += stat.eliminations; + existing.deaths += stat.deaths; + } else { + aggregated.set(key, { + hero: stat.player_hero, + role, + timePlayed: stat.hero_time_played, + eliminations: stat.eliminations, + deaths: stat.deaths, + }); + } + } + } + + const results = Array.from(aggregated.entries()).map(([key, value]) => ({ + playerName: key.split("__")[0], + hero: value.hero, + role: value.role, + timePlayed: value.timePlayed, + eliminations: value.eliminations, + deaths: value.deaths, + source: "scrim" as const, + })); + + wideEvent.result_count = results.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimOpponentPlayerStatsSuccessTotal); + return results; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scrimOpponentPlayerStatsErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getOpponentScrimPlayerStats") + : Effect.logInfo("scrim.getOpponentScrimPlayerStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimOpponentPlayerStatsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getOpponentScrimPlayerStats") + ); + } + + function opponentCacheKeyOf(userTeamId: number, opponentAbbr: string) { return JSON.stringify({ userTeamId, opponentAbbr }); } + + const mapResultsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { userTeamId, opponentAbbr } = JSON.parse(key) as { + userTeamId: number; + opponentAbbr: string; + }; + return getOpponentScrimMapResults(userTeamId, opponentAbbr).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + const heroBansCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { userTeamId, opponentAbbr } = JSON.parse(key) as { + userTeamId: number; + opponentAbbr: string; + }; + return getOpponentScrimHeroBans(userTeamId, opponentAbbr).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + const playerStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { userTeamId, opponentAbbr } = JSON.parse(key) as { + userTeamId: number; + opponentAbbr: string; + }; + return getOpponentScrimPlayerStats(userTeamId, opponentAbbr).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + return { + getOpponentScrimMapResults: (userTeamId: number, opponentAbbr: string) => + mapResultsCache + .get(opponentCacheKeyOf(userTeamId, opponentAbbr)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getOpponentScrimHeroBans: (userTeamId: number, opponentAbbr: string) => + heroBansCache + .get(opponentCacheKeyOf(userTeamId, opponentAbbr)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getOpponentScrimPlayerStats: (userTeamId: number, opponentAbbr: string) => + playerStatsCache + .get(opponentCacheKeyOf(userTeamId, opponentAbbr)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + } satisfies ScrimOpponentServiceInterface; +}); + +export const ScrimOpponentServiceLive = Layer.effect( + ScrimOpponentService, + make +).pipe( + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/scrim/overview-service.ts b/src/data/scrim/overview-service.ts new file mode 100644 index 000000000..4bdcba6a7 --- /dev/null +++ b/src/data/scrim/overview-service.ts @@ -0,0 +1,2335 @@ +import { + aggregatePlayerStats, + calculateTrends, +} from "@/data/comparison/computation"; +import type { TrendsAnalysis } from "@/data/comparison/types"; +import { + ScrimAbilityTimingService, + ScrimAbilityTimingServiceLive, +} from "@/data/scrim/ability-timing-service"; +import { + filterUtilityRoundStartSwaps, + type SwapRecord, + type SwapTimingOutcome, + type SwapWinrateBucket, +} from "@/data/team/hero-swap-service"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { ValidStatColumn } from "@/lib/stat-percentiles"; +import { + groupEventsIntoFights, + mercyRezToKillEvent, + removeDuplicateRows, + ultimateStartToKillEvent, + type Fight, +} from "@/lib/utils"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName, RoleName, SubroleName } from "@/types/heroes"; +import { + getHeroRole, + heroRoleMapping, + ROLE_SUBROLES, + SUBROLE_DISPLAY_NAMES, +} from "@/types/heroes"; +import { + Prisma, + type CalculatedStat, + type Kill, + type MatchStart, + type ObjectiveCaptured, + type PlayerStat, + type RoundEnd, +} from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScrimQueryError } from "./errors"; +import { + scrimCacheMissTotal, + scrimCacheRequestTotal, + scrimOverviewDuration, + scrimOverviewErrorTotal, + scrimOverviewSuccessTotal, +} from "./metrics"; +import type { + FightInitiatingUlt, + MapResult, + PlayerMapPerformance, + PlayerScrimPerformance, + ScrimFightAnalysis, + ScrimInsight, + ScrimOutlier, + ScrimOverviewData, + ScrimSwapAnalysis, + ScrimUltAnalysis, + ScrimUltRoleBreakdown, + UltEfficiency, +} from "./types"; +import { + assignPlayersToSubroles, + buildPlayerUltComparisons, + type SubroleUltTiming, +} from "./ult-helpers"; + +export type { + FightInitiatingUlt, + MapResult, + PlayerMapPerformance, + PlayerScrimPerformance, + ScrimFightAnalysis, + ScrimInsight, + ScrimOutlier, + ScrimOverviewData, + ScrimSwapAnalysis, + ScrimTeamTotals, + ScrimUltAnalysis, + ScrimUltRoleBreakdown, + UltEfficiency, +} from "./types"; + +export type { PlayerUltComparison, SubroleUltTiming } from "./ult-helpers"; + +export type ScrimOverviewServiceInterface = { + readonly getScrimOverview: ( + scrimId: number, + teamId: number + ) => Effect.Effect; +}; + +export class ScrimOverviewService extends Context.Tag( + "@app/data/scrim/ScrimOverviewService" +)() {} + +const STAT_LABELS: Record = { + eliminations: "Eliminations", + final_blows: "Final Blows", + deaths: "Deaths", + hero_damage_dealt: "Hero Damage", + healing_dealt: "Healing", + healing_received: "Healing Received", + damage_blocked: "Damage Blocked", + damage_taken: "Damage Taken", + solo_kills: "Solo Kills", + ultimates_earned: "Ults Earned", + ultimates_used: "Ults Used", + objective_kills: "Objective Kills", + offensive_assists: "Offensive Assists", + defensive_assists: "Defensive Assists", +}; + +function statLabelFor(stat: ValidStatColumn): string { + return STAT_LABELS[stat] ?? stat; +} + +function statsForHeroRole( + primaryHero: HeroName, + heroDamageDealt: number, + healingDealt: number, + damageBlocked: number, + deaths: number, + eliminations: number +): { stat: ValidStatColumn; value: number }[] { + const role = heroRoleMapping[primaryHero]; + const base: { stat: ValidStatColumn; value: number }[] = [ + { stat: "eliminations", value: eliminations }, + { stat: "deaths", value: deaths }, + { stat: "hero_damage_dealt", value: heroDamageDealt }, + ]; + + if (role === "Support") { + base.push({ stat: "healing_dealt", value: healingDealt }); + } else if (role === "Tank") { + base.push({ stat: "damage_blocked", value: damageBlocked }); + } + + return base; +} + +function determineTrend( + trendData?: TrendsAnalysis +): "improving" | "stable" | "declining" { + if (!trendData) return "stable"; + const improving = trendData.improvingMetrics.length; + const declining = trendData.decliningMetrics.length; + if (improving > declining && improving > 0) return "improving"; + if (declining > improving && declining > 0) return "declining"; + return "stable"; +} + +function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { + const insights: ScrimInsight[] = []; + + if (players.length === 0) return insights; + + const eligibleForMvp = players.filter((p) => p.mapsPlayed >= 2); + if (eligibleForMvp.length > 0) { + const mvp = eligibleForMvp.reduce((best, p) => + p.eliminationsPer10 > best.eliminationsPer10 ? p : best + ); + if (mvp.eliminationsPer10 > 0) { + insights.push({ + type: "mvp", + headline: `${mvp.playerName} led with ${mvp.eliminationsPer10.toFixed(1)} elims/10 min on ${mvp.primaryHero}`, + playerName: mvp.playerName, + }); + } + } + + const mostImproved = players + .filter((p) => p.trendData && p.trendData.improvingMetrics.length > 0) + .sort( + (a, b) => + (b.trendData?.improvingMetrics.length ?? 0) - + (a.trendData?.improvingMetrics.length ?? 0) + )[0]; + + if (mostImproved) { + const topMetric = mostImproved.trendData!.improvingMetrics[0]; + const sign = topMetric.changePercentage > 0 ? "+" : ""; + insights.push({ + type: "most_improved", + headline: `${mostImproved.playerName} improved across maps (+${sign}${topMetric.changePercentage.toFixed(0)}% ${topMetric.metric.toLowerCase()})`, + playerName: mostImproved.playerName, + }); + } + + const mostDeclined = players + .filter((p) => p.trendData && p.trendData.decliningMetrics.length > 0) + .sort( + (a, b) => + (b.trendData?.decliningMetrics.length ?? 0) - + (a.trendData?.decliningMetrics.length ?? 0) + )[0]; + + if (mostDeclined && mostDeclined.playerName !== mostImproved?.playerName) { + const topMetric = mostDeclined.trendData!.decliningMetrics[0]; + insights.push({ + type: "most_declined", + headline: `${mostDeclined.playerName} declined across maps (${topMetric.changePercentage.toFixed(0)}% ${topMetric.metric.toLowerCase()})`, + playerName: mostDeclined.playerName, + }); + } + + const positiveOutliers = players + .flatMap((p) => + p.outliers + .filter((o) => o.direction === "high") + .map((o) => ({ player: p, outlier: o })) + ) + .sort((a, b) => b.outlier.zScore - a.outlier.zScore); + + if (positiveOutliers.length > 0) { + const { player, outlier } = positiveOutliers[0]; + insights.push({ + type: "outlier_positive", + headline: `${player.playerName}'s ${statLabelFor(outlier.stat).toLowerCase()} ranks at the ${outlier.percentile}th percentile vs database`, + playerName: player.playerName, + }); + } + + const negativeOutliers = players + .flatMap((p) => + p.outliers + .filter((o) => o.direction === "low") + .map((o) => ({ player: p, outlier: o })) + ) + .sort((a, b) => a.outlier.zScore - b.outlier.zScore); + + if (negativeOutliers.length > 0 && insights.length < 4) { + const { player, outlier } = negativeOutliers[0]; + insights.push({ + type: "outlier_negative", + headline: `${player.playerName}'s ${statLabelFor(outlier.stat).toLowerCase()} is below average for ${player.primaryHero} (${outlier.percentile}th %ile)`, + playerName: player.playerName, + }); + } + + return insights.slice(0, 4); +} + +function emptyFightAnalysis(): ScrimFightAnalysis { + return { + totalFights: 0, + fightsWon: 0, + fightWinrate: 0, + teamFirstDeathCount: 0, + teamFirstDeathRate: 0, + firstDeathWinrate: 0, + firstPickCount: 0, + firstPickRate: 0, + firstPickWinrate: 0, + firstUltCount: 0, + firstUltRate: 0, + firstUltWinrate: 0, + opponentFirstUltCount: 0, + opponentFirstUltWinrate: 0, + }; +} + +function emptyUltAnalysis(): ScrimUltAnalysis { + return { + ourUltsUsed: 0, + opponentUltsUsed: 0, + ultsByRole: [], + topUltUser: null, + avgChargeTime: 0, + avgHoldTime: 0, + playerComparisons: [], + ourFightInitiations: 0, + opponentFightInitiations: 0, + fightsWithUlts: 0, + ourTopFightInitiator: null, + opponentTopFightInitiator: null, + ultEfficiency: { + ultimateEfficiency: 0, + avgUltsInWonFights: 0, + avgUltsInLostFights: 0, + wastedUltimates: 0, + totalUltsUsedInFights: 0, + fightsWon: 0, + fightsLost: 0, + dryFights: 0, + dryFightWins: 0, + dryFightWinrate: 0, + dryFightReversals: 0, + dryFightReversalRate: 0, + nonDryFights: 0, + nonDryFightReversals: 0, + nonDryFightReversalRate: 0, + }, + }; +} + +function emptySwapAnalysis(): ScrimSwapAnalysis { + return { + ourSwaps: 0, + opponentSwaps: 0, + ourSwapsPerMap: 0, + opponentSwapsPerMap: 0, + mapsWithOurSwaps: 0, + mapsWithoutOurSwaps: 0, + noSwapWinrate: 0, + noSwapWins: 0, + noSwapLosses: 0, + swapWinrate: 0, + swapWins: 0, + swapLosses: 0, + avgHeroTimeBeforeSwap: 0, + ourTopSwap: null, + opponentTopSwap: null, + topSwapper: null, + winrateBySwapCount: [], + timingOutcomes: [], + }; +} + +function emptyOverviewData(): ScrimOverviewData { + return { + mapCount: 0, + wins: 0, + losses: 0, + draws: 0, + ourTeamName: "", + opponentTeamName: "", + mapResults: [], + teamPlayers: [], + insights: [], + teamTotals: { + eliminations: 0, + deaths: 0, + heroDamage: 0, + healing: 0, + kdRatio: 0, + }, + fightAnalysis: emptyFightAnalysis(), + ultAnalysis: emptyUltAnalysis(), + swapAnalysis: emptySwapAnalysis(), + abilityTimingAnalysis: { rows: [], outliers: [] }, + }; +} + +function aggregateRowsToPlayerStat(rows: PlayerStat[]): PlayerStat | null { + if (rows.length === 0) return null; + const base = rows[0]; + const totals = rows.reduce( + (acc, row) => { + acc.eliminations += row.eliminations; + acc.final_blows += row.final_blows; + acc.deaths += row.deaths; + acc.all_damage_dealt += row.all_damage_dealt; + acc.barrier_damage_dealt += row.barrier_damage_dealt; + acc.hero_damage_dealt += row.hero_damage_dealt; + acc.healing_dealt += row.healing_dealt; + acc.healing_received += row.healing_received; + acc.self_healing += row.self_healing; + acc.damage_taken += row.damage_taken; + acc.damage_blocked += row.damage_blocked; + acc.defensive_assists += row.defensive_assists; + acc.offensive_assists += row.offensive_assists; + acc.ultimates_earned += row.ultimates_earned; + acc.ultimates_used += row.ultimates_used; + acc.multikill_best = Math.max(acc.multikill_best, row.multikill_best); + acc.multikills += row.multikills; + acc.solo_kills += row.solo_kills; + acc.objective_kills += row.objective_kills; + acc.environmental_kills += row.environmental_kills; + acc.environmental_deaths += row.environmental_deaths; + acc.critical_hits += row.critical_hits; + acc.scoped_critical_hit_kills += row.scoped_critical_hit_kills; + acc.shots_fired += row.shots_fired; + acc.shots_hit += row.shots_hit; + acc.shots_missed += row.shots_missed; + acc.scoped_shots += row.scoped_shots; + acc.scoped_shots_hit += row.scoped_shots_hit; + acc.hero_time_played += row.hero_time_played; + return acc; + }, + { + eliminations: 0, + final_blows: 0, + deaths: 0, + all_damage_dealt: 0, + barrier_damage_dealt: 0, + hero_damage_dealt: 0, + healing_dealt: 0, + healing_received: 0, + self_healing: 0, + damage_taken: 0, + damage_blocked: 0, + defensive_assists: 0, + offensive_assists: 0, + ultimates_earned: 0, + ultimates_used: 0, + multikill_best: 0, + multikills: 0, + solo_kills: 0, + objective_kills: 0, + environmental_kills: 0, + environmental_deaths: 0, + critical_hits: 0, + scoped_critical_hit_kills: 0, + shots_fired: 0, + shots_hit: 0, + shots_missed: 0, + scoped_shots: 0, + scoped_shots_hit: 0, + hero_time_played: 0, + } + ); + return { ...base, ...totals }; +} + +function getMapTeamNames(stats: PlayerStat[]): Map { + const countsByMap = new Map>(); + for (const row of stats) { + if (!row.MapDataId) continue; + if (!countsByMap.has(row.MapDataId)) { + countsByMap.set(row.MapDataId, new Map()); + } + const mapCounts = countsByMap.get(row.MapDataId)!; + mapCounts.set(row.player_team, (mapCounts.get(row.player_team) ?? 0) + 1); + } + + const result = new Map(); + for (const [mapId, teamCounts] of countsByMap.entries()) { + let selectedTeam: string | null = null; + let maxCount = -1; + for (const [teamName, count] of teamCounts.entries()) { + if (count > maxCount) { + maxCount = count; + selectedTeam = teamName; + } + } + if (selectedTeam) { + result.set(mapId, selectedTeam); + } + } + return result; +} + +function createBaselineKey(hero: HeroName, stat: ValidStatColumn): string { + return `${hero}::${stat}`; +} + +function erf(x: number): number { + const sign = x >= 0 ? 1 : -1; + const absX = Math.abs(x); + const a1 = 0.254829592; + const a2 = -0.284496736; + const a3 = 1.421413741; + const a4 = -1.453152027; + const a5 = 1.061405429; + const p = 0.3275911; + const t = 1 / (1 + p * absX); + const y = + 1 - + ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-absX * absX); + return sign * y; +} + +function normalCdf(z: number): number { + return 0.5 * (1 + erf(z / Math.SQRT2)); +} + +type StatDistributionBaseline = { + hero: string; + stat_name: string; + hero_avg_per10: number; + hero_std_per10: number; + total_players: number; +}; + +type BatchedBaselineRow = { + hero: string; + avg_eliminations_per10: number | null; + std_eliminations_per10: number | null; + avg_deaths_per10: number | null; + std_deaths_per10: number | null; + avg_hero_damage_dealt_per10: number | null; + std_hero_damage_dealt_per10: number | null; + avg_healing_dealt_per10: number | null; + std_healing_dealt_per10: number | null; + avg_damage_blocked_per10: number | null; + std_damage_blocked_per10: number | null; + total_players: number; +}; + +const BASELINE_STAT_COLUMNS: ValidStatColumn[] = [ + "eliminations", + "deaths", + "hero_damage_dealt", + "healing_dealt", + "damage_blocked", +]; + +async function getBatchedStatDistributionBaselines( + heroes: HeroName[], + minMaps = 5, + minTimeSeconds = 300, + sampleLimit = 150 +): Promise> { + if (heroes.length === 0) return new Map(); + + const rows = await prisma.$queryRaw` + WITH + final_rows AS ( + SELECT DISTINCT ON ("MapDataId", player_name, player_hero) + player_name, + player_hero, + eliminations, + deaths, + hero_damage_dealt, + healing_dealt, + damage_blocked, + hero_time_played + FROM + "PlayerStat" + WHERE + player_hero IN (${Prisma.join(heroes)}) + AND hero_time_played >= 60 + ORDER BY + "MapDataId", + player_name, + player_hero, + round_number DESC, + id DESC + ), + per_player_totals AS ( + SELECT + player_hero, + player_name, + COUNT(*) AS maps, + (SUM(eliminations)::numeric / SUM(hero_time_played)) * 600.0 AS elims_per10, + (SUM(deaths)::numeric / SUM(hero_time_played)) * 600.0 AS deaths_per10, + (SUM(hero_damage_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS dmg_per10, + (SUM(healing_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS heal_per10, + (SUM(damage_blocked)::numeric / SUM(hero_time_played)) * 600.0 AS block_per10 + FROM + final_rows + GROUP BY + player_name, player_hero + HAVING + COUNT(*) >= ${minMaps} + AND SUM(hero_time_played) >= ${minTimeSeconds} + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY player_hero + ORDER BY maps DESC, player_name + ) AS rn + FROM + per_player_totals + ), + limited AS ( + SELECT * FROM ranked WHERE rn <= ${sampleLimit} + ) + SELECT + player_hero AS hero, + ROUND(AVG(elims_per10)::numeric, 2) AS avg_eliminations_per10, + ROUND(STDDEV_SAMP(elims_per10)::numeric, 2) AS std_eliminations_per10, + ROUND(AVG(deaths_per10)::numeric, 2) AS avg_deaths_per10, + ROUND(STDDEV_SAMP(deaths_per10)::numeric, 2) AS std_deaths_per10, + ROUND(AVG(dmg_per10)::numeric, 2) AS avg_hero_damage_dealt_per10, + ROUND(STDDEV_SAMP(dmg_per10)::numeric, 2) AS std_hero_damage_dealt_per10, + ROUND(AVG(heal_per10)::numeric, 2) AS avg_healing_dealt_per10, + ROUND(STDDEV_SAMP(heal_per10)::numeric, 2) AS std_healing_dealt_per10, + ROUND(AVG(block_per10)::numeric, 2) AS avg_damage_blocked_per10, + ROUND(STDDEV_SAMP(block_per10)::numeric, 2) AS std_damage_blocked_per10, + COUNT(*)::int AS total_players + FROM + limited + GROUP BY + player_hero + `; + + const statColumnMapping: Record< + string, + { avg: keyof BatchedBaselineRow; std: keyof BatchedBaselineRow } + > = { + eliminations: { + avg: "avg_eliminations_per10", + std: "std_eliminations_per10", + }, + deaths: { avg: "avg_deaths_per10", std: "std_deaths_per10" }, + hero_damage_dealt: { + avg: "avg_hero_damage_dealt_per10", + std: "std_hero_damage_dealt_per10", + }, + healing_dealt: { + avg: "avg_healing_dealt_per10", + std: "std_healing_dealt_per10", + }, + damage_blocked: { + avg: "avg_damage_blocked_per10", + std: "std_damage_blocked_per10", + }, + }; + + const result = new Map(); + for (const row of rows) { + for (const stat of BASELINE_STAT_COLUMNS) { + const mapping = statColumnMapping[stat]; + const avg = Number(row[mapping.avg]); + const std = Number(row[mapping.std]); + if (Number.isNaN(avg) || Number.isNaN(std)) continue; + + result.set(createBaselineKey(row.hero as HeroName, stat), { + hero: row.hero, + stat_name: stat, + hero_avg_per10: avg, + hero_std_per10: std, + total_players: row.total_players, + }); + } + } + + return result; +} + +function calculateZScoreFromBaseline({ + baseline, + stat, + per10Value, +}: { + baseline: StatDistributionBaseline; + stat: ValidStatColumn; + per10Value: number; +}): number | null { + if (!baseline.hero_std_per10 || baseline.hero_std_per10 === 0) return null; + const isInverted = stat === "deaths" || stat === "damage_taken"; + const zScore = isInverted + ? (baseline.hero_avg_per10 - per10Value) / baseline.hero_std_per10 + : (per10Value - baseline.hero_avg_per10) / baseline.hero_std_per10; + if (!Number.isFinite(zScore)) return null; + return zScore; +} + +type ExtendedFightEvent = Kill & { + ultimate_id?: number; +}; + +type ExtendedFight = { + events: ExtendedFightEvent[]; + start: number; + end: number; +}; + +const FIGHT_GAP_SECONDS = 15; + +function computeScrimFightAnalysis( + mapIds: number[], + killsByMap: Map, + allUltimates: { + id: number; + scrimId: number; + match_time: number; + player_team: string; + player_name: string; + player_hero: string; + ultimate_id: number; + MapDataId: number | null; + }[], + ourTeamNameByMap: Map +): ScrimFightAnalysis { + const ultsByMap = new Map(); + for (const ult of allUltimates) { + if (!ult.MapDataId) continue; + if (!ultsByMap.has(ult.MapDataId)) { + ultsByMap.set(ult.MapDataId, []); + } + ultsByMap.get(ult.MapDataId)!.push(ult); + } + + let totalFights = 0; + let fightsWon = 0; + let teamFirstDeathCount = 0; + let firstDeathWins = 0; + let firstPickCount = 0; + let firstPickWins = 0; + let firstUltCount = 0; + let firstUltWins = 0; + let opponentFirstUltCount = 0; + let opponentFirstUltWins = 0; + + for (const mapId of mapIds) { + const ourTeamName = ourTeamNameByMap.get(mapId); + if (!ourTeamName) continue; + + const kills = killsByMap.get(mapId) ?? []; + const ults = ultsByMap.get(mapId) ?? []; + + if (kills.length === 0 && ults.length === 0) continue; + + const events: ExtendedFightEvent[] = [ + ...kills, + ...ults.map((ult) => ({ + ...ultimateStartToKillEvent(ult), + ultimate_id: ult.ultimate_id, + })), + ]; + + events.sort((a, b) => a.match_time - b.match_time); + + const fights: ExtendedFight[] = []; + let currentFight: ExtendedFight | null = null; + + for (const event of events) { + if ( + !currentFight || + event.match_time - currentFight.end > FIGHT_GAP_SECONDS + ) { + currentFight = { + events: [event], + start: event.match_time, + end: event.match_time, + }; + fights.push(currentFight); + } else { + currentFight.events.push(event); + currentFight.end = event.match_time; + } + } + + for (const fight of fights) { + const sorted = [...fight.events].sort( + (a, b) => a.match_time - b.match_time + ); + + const killEvents = sorted.filter( + (e) => e.event_type === "kill" || e.event_type === "mercy_rez" + ); + const ultEvents = sorted.filter((e) => e.event_type === "ultimate_start"); + + let ourKills = 0; + let enemyKills = 0; + + for (const event of sorted) { + if (event.event_type === "mercy_rez") { + if (event.victim_team === ourTeamName) { + enemyKills = Math.max(0, enemyKills - 1); + } else { + ourKills = Math.max(0, ourKills - 1); + } + } else if (event.event_type === "kill") { + if (event.attacker_team === ourTeamName) { + ourKills++; + } else { + enemyKills++; + } + } + } + + const won = ourKills > enemyKills; + + totalFights++; + if (won) fightsWon++; + + const firstKill = killEvents.find((k) => k.event_type === "kill"); + if (firstKill) { + if (firstKill.attacker_team === ourTeamName) { + firstPickCount++; + if (won) firstPickWins++; + } + if (firstKill.victim_team === ourTeamName) { + teamFirstDeathCount++; + if (won) firstDeathWins++; + } + } + + const firstUlt = ultEvents[0]; + if (firstUlt) { + if (firstUlt.attacker_team === ourTeamName) { + firstUltCount++; + if (won) firstUltWins++; + } else { + opponentFirstUltCount++; + if (won) opponentFirstUltWins++; + } + } + } + } + + return { + totalFights, + fightsWon, + fightWinrate: totalFights > 0 ? (fightsWon / totalFights) * 100 : 0, + teamFirstDeathCount, + teamFirstDeathRate: + totalFights > 0 ? (teamFirstDeathCount / totalFights) * 100 : 0, + firstDeathWinrate: + teamFirstDeathCount > 0 + ? (firstDeathWins / teamFirstDeathCount) * 100 + : 0, + firstPickCount, + firstPickRate: totalFights > 0 ? (firstPickCount / totalFights) * 100 : 0, + firstPickWinrate: + firstPickCount > 0 ? (firstPickWins / firstPickCount) * 100 : 0, + firstUltCount, + firstUltRate: totalFights > 0 ? (firstUltCount / totalFights) * 100 : 0, + firstUltWinrate: + firstUltCount > 0 ? (firstUltWins / firstUltCount) * 100 : 0, + opponentFirstUltCount, + opponentFirstUltWinrate: + opponentFirstUltCount > 0 + ? (opponentFirstUltWins / opponentFirstUltCount) * 100 + : 0, + }; +} + +type UltStartRecord = { + player_team: string; + player_name: string; + player_hero: string; + match_time: number; + MapDataId: number | null; +}; + +function computeScrimUltAnalysis( + allUltimates: UltStartRecord[], + fightsByMap: Map, + ourTeamNameByMap: Map, + calculatedStats: CalculatedStat[], + scrimPlayers: string[] +): ScrimUltAnalysis { + const roles: RoleName[] = ["Tank", "Damage", "Support"]; + + let ourUltsUsed = 0; + let opponentUltsUsed = 0; + const ourByRole: Record = { + Tank: 0, + Damage: 0, + Support: 0, + }; + const oppByRole: Record = { + Tank: 0, + Damage: 0, + Support: 0, + }; + const firstByRole: Record< + RoleName, + { ourFirst: number; oppFirst: number; total: number } + > = { + Tank: { ourFirst: 0, oppFirst: 0, total: 0 }, + Damage: { ourFirst: 0, oppFirst: 0, total: 0 }, + Support: { ourFirst: 0, oppFirst: 0, total: 0 }, + }; + + type PlayerUltEntry = { + heroCountMap: Map; + totalCount: number; + }; + const ourPlayerUltCounts = new Map(); + const oppPlayerUltCounts = new Map(); + + function trackPlayerUlt( + map: Map, + playerName: string, + hero: string + ) { + let entry = map.get(playerName); + if (!entry) { + entry = { heroCountMap: new Map(), totalCount: 0 }; + map.set(playerName, entry); + } + entry.totalCount++; + entry.heroCountMap.set(hero, (entry.heroCountMap.get(hero) ?? 0) + 1); + } + + type SubroleTimingAccum = { + count: number; + initiation: number; + midfight: number; + late: number; + }; + + function createSubroleTimingMap() { + const map = new Map>(); + for (const role of roles) { + const inner = new Map(); + for (const sr of ROLE_SUBROLES[role]) { + inner.set(sr, { count: 0, initiation: 0, midfight: 0, late: 0 }); + } + map.set(role, inner); + } + return map; + } + + const ourSubroleTimingByRole = createSubroleTimingMap(); + const oppSubroleTimingByRole = createSubroleTimingMap(); + + const fightInitiatingUlts = new Map< + string, + { hero: string; count: number; ourCount: number; oppCount: number } + >(); + let ourFightInitiations = 0; + let opponentFightInitiations = 0; + let fightsWithUlts = 0; + + let effFightsWon = 0; + let effFightsLost = 0; + let ultsInWonFights = 0; + let ultsInLostFights = 0; + let totalWastedUlts = 0; + let totalOurUltsInFights = 0; + let dryFights = 0; + let dryFightWins = 0; + let dryFightReversals = 0; + let nonDryFightReversals = 0; + + const ultsByMap = new Map(); + for (const ult of allUltimates) { + if (!ult.MapDataId) continue; + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); + ultsByMap.get(ult.MapDataId)!.push(ult); + } + + for (const [mapId, ults] of ultsByMap) { + const ourTeamName = ourTeamNameByMap.get(mapId); + if (!ourTeamName) continue; + for (const ult of ults) { + if (ult.player_team === ourTeamName) { + trackPlayerUlt(ourPlayerUltCounts, ult.player_name, ult.player_hero); + } else { + trackPlayerUlt(oppPlayerUltCounts, ult.player_name, ult.player_hero); + } + } + } + + const ourSubroleAssignment = assignPlayersToSubroles(ourPlayerUltCounts); + const oppSubroleAssignment = assignPlayersToSubroles(oppPlayerUltCounts); + + const ourPlayerSubrole = new Map(); + for (const [sr, candidate] of ourSubroleAssignment) { + ourPlayerSubrole.set(candidate.playerName, sr); + } + const oppPlayerSubrole = new Map(); + for (const [sr, candidate] of oppSubroleAssignment) { + oppPlayerSubrole.set(candidate.playerName, sr); + } + + for (const [mapId, ults] of ultsByMap) { + const ourTeamName = ourTeamNameByMap.get(mapId); + if (!ourTeamName) continue; + + for (const ult of ults) { + const role = getHeroRole(ult.player_hero); + if (ult.player_team === ourTeamName) { + ourUltsUsed++; + ourByRole[role]++; + } else { + opponentUltsUsed++; + oppByRole[role]++; + } + } + + const fights = fightsByMap.get(mapId) ?? []; + for (const fight of fights) { + const fightUlts = ults.filter( + (u) => u.match_time >= fight.start && u.match_time <= fight.end + 15 + ); + + if (fightUlts.length > 0) { + const opener = fightUlts[0]; + fightsWithUlts++; + const isOurs = opener.player_team === ourTeamName; + if (isOurs) ourFightInitiations++; + else opponentFightInitiations++; + + const key = opener.player_hero; + const existing = fightInitiatingUlts.get(key); + if (existing) { + existing.count++; + if (isOurs) existing.ourCount++; + else existing.oppCount++; + } else { + fightInitiatingUlts.set(key, { + hero: opener.player_hero, + count: 1, + ourCount: isOurs ? 1 : 0, + oppCount: isOurs ? 0 : 1, + }); + } + } + + for (const role of roles) { + const first = fightUlts.find( + (u) => getHeroRole(u.player_hero) === role + ); + if (!first) continue; + firstByRole[role].total++; + if (first.player_team === ourTeamName) firstByRole[role].ourFirst++; + else firstByRole[role].oppFirst++; + } + + const fightDuration = fight.end + 15 - fight.start; + const thirdDuration = fightDuration / 3; + for (const ult of fightUlts) { + const isOurUlt = ult.player_team === ourTeamName; + const playerLookup = isOurUlt ? ourPlayerSubrole : oppPlayerSubrole; + const subrole = playerLookup.get(ult.player_name); + if (!subrole) continue; + const role = getHeroRole(ult.player_hero); + + const timingMap = isOurUlt + ? ourSubroleTimingByRole + : oppSubroleTimingByRole; + const accum = timingMap.get(role)?.get(subrole); + if (!accum) continue; + accum.count++; + + const elapsed = ult.match_time - fight.start; + if (fightDuration <= 0 || elapsed < thirdDuration) { + accum.initiation++; + } else if (elapsed < thirdDuration * 2) { + accum.midfight++; + } else { + accum.late++; + } + } + + const ourUltCount = fightUlts.filter( + (u) => u.player_team === ourTeamName + ).length; + + let ourKills = 0; + let enemyKills = 0; + let wasDown2Plus = false; + for (const k of fight.kills) { + if (k.event_type === "mercy_rez") { + if (k.victim_team === ourTeamName) { + enemyKills = Math.max(0, enemyKills - 1); + } else { + ourKills = Math.max(0, ourKills - 1); + } + } else if (k.event_type === "kill") { + if (k.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + if (enemyKills - ourKills >= 2) { + wasDown2Plus = true; + } + } + const won = ourKills > enemyKills; + const isReversal = won && wasDown2Plus; + + if (ourUltCount === 0) { + dryFights++; + if (won) dryFightWins++; + if (isReversal) dryFightReversals++; + } else { + totalOurUltsInFights += ourUltCount; + if (won) { + effFightsWon++; + ultsInWonFights += ourUltCount; + } else { + effFightsLost++; + ultsInLostFights += ourUltCount; + } + if (isReversal) nonDryFightReversals++; + + const sortedKills = [...fight.kills].sort( + (a, b) => a.match_time - b.match_time + ); + for (const ult of fightUlts) { + if (ult.player_team !== ourTeamName) continue; + let runOur = 0; + let runEnemy = 0; + for (const k of sortedKills) { + if (k.match_time > ult.match_time) break; + if (k.event_type === "mercy_rez") { + if (k.victim_team === ourTeamName) + runEnemy = Math.max(0, runEnemy - 1); + else runOur = Math.max(0, runOur - 1); + } else if (k.event_type === "kill") { + if (k.attacker_team === ourTeamName) runOur++; + else runEnemy++; + } + } + if (runOur - runEnemy <= -3) totalWastedUlts++; + } + } + } + } + + function extractTimings( + timingMap: Map>, + role: RoleName + ): SubroleUltTiming[] { + const result: SubroleUltTiming[] = []; + const inner = timingMap.get(role)!; + for (const sr of ROLE_SUBROLES[role]) { + const accum = inner.get(sr)!; + if (accum.count > 0) { + result.push({ + subrole: SUBROLE_DISPLAY_NAMES[sr], + count: accum.count, + initiation: accum.initiation, + midfight: accum.midfight, + late: accum.late, + }); + } + } + return result; + } + + const ultsByRole: ScrimUltRoleBreakdown[] = roles.map((role) => ({ + role, + ourCount: ourByRole[role], + opponentCount: oppByRole[role], + ourFirstRate: + firstByRole[role].total > 0 + ? (firstByRole[role].ourFirst / firstByRole[role].total) * 100 + : 0, + ourSubroleTimings: extractTimings(ourSubroleTimingByRole, role), + opponentSubroleTimings: extractTimings(oppSubroleTimingByRole, role), + })); + + let topUltUser: ScrimUltAnalysis["topUltUser"] = null; + for (const [name, entry] of ourPlayerUltCounts) { + if (!topUltUser || entry.totalCount > topUltUser.count) { + let bestHero = ""; + let bestCount = 0; + for (const [hero, count] of entry.heroCountMap) { + if (count > bestCount) { + bestCount = count; + bestHero = hero; + } + } + topUltUser = { + playerName: name, + hero: bestHero, + count: entry.totalCount, + }; + } + } + + const scrimPlayerSet = new Set(scrimPlayers); + const chargeTimeValues: number[] = []; + const holdTimeValues: number[] = []; + for (const cs of calculatedStats) { + if (!scrimPlayerSet.has(cs.playerName) || cs.value <= 0) continue; + if (cs.stat === "AVERAGE_ULT_CHARGE_TIME") chargeTimeValues.push(cs.value); + else if (cs.stat === "AVERAGE_TIME_TO_USE_ULT") + holdTimeValues.push(cs.value); + } + + const avgChargeTime = + chargeTimeValues.length > 0 + ? chargeTimeValues.reduce((a, b) => a + b, 0) / chargeTimeValues.length + : 0; + const avgHoldTime = + holdTimeValues.length > 0 + ? holdTimeValues.reduce((a, b) => a + b, 0) / holdTimeValues.length + : 0; + + const playerComparisons = buildPlayerUltComparisons( + ourPlayerUltCounts, + oppPlayerUltCounts + ); + + let ourTopFightInitiator: FightInitiatingUlt | null = null; + let opponentTopFightInitiator: FightInitiatingUlt | null = null; + for (const entry of fightInitiatingUlts.values()) { + if ( + entry.ourCount > 0 && + (!ourTopFightInitiator || entry.ourCount > ourTopFightInitiator.count) + ) { + ourTopFightInitiator = { + hero: entry.hero, + count: entry.ourCount, + isOurTeam: true, + }; + } + if ( + entry.oppCount > 0 && + (!opponentTopFightInitiator || + entry.oppCount > opponentTopFightInitiator.count) + ) { + opponentTopFightInitiator = { + hero: entry.hero, + count: entry.oppCount, + isOurTeam: false, + }; + } + } + + const nonDryFights = effFightsWon + effFightsLost; + const ultEfficiency: UltEfficiency = { + ultimateEfficiency: + totalOurUltsInFights > 0 ? effFightsWon / totalOurUltsInFights : 0, + avgUltsInWonFights: effFightsWon > 0 ? ultsInWonFights / effFightsWon : 0, + avgUltsInLostFights: + effFightsLost > 0 ? ultsInLostFights / effFightsLost : 0, + wastedUltimates: totalWastedUlts, + totalUltsUsedInFights: totalOurUltsInFights, + fightsWon: effFightsWon, + fightsLost: nonDryFights - effFightsWon, + dryFights, + dryFightWins, + dryFightWinrate: dryFights > 0 ? (dryFightWins / dryFights) * 100 : 0, + dryFightReversals, + dryFightReversalRate: + dryFights > 0 ? (dryFightReversals / dryFights) * 100 : 0, + nonDryFights, + nonDryFightReversals, + nonDryFightReversalRate: + nonDryFights > 0 ? (nonDryFightReversals / nonDryFights) * 100 : 0, + }; + + return { + ourUltsUsed, + opponentUltsUsed, + ultsByRole, + topUltUser, + avgChargeTime, + avgHoldTime, + playerComparisons, + ourFightInitiations, + opponentFightInitiations, + fightsWithUlts, + ourTopFightInitiator, + opponentTopFightInitiator, + ultEfficiency, + }; +} + +function computeScrimSwapAnalysis( + allHeroSwaps: SwapRecord[], + allRoundStarts: { match_time: number; MapDataId: number | null }[], + allMatchEnds: { match_time: number; MapDataId: number | null }[], + mapIds: number[], + ourTeamNameByMap: Map, + mapResults: MapResult[] +): ScrimSwapAnalysis { + if (allHeroSwaps.length === 0) return emptySwapAnalysis(); + + const roundStartsByMap = new Map(); + for (const rs of allRoundStarts) { + if (!rs.MapDataId) continue; + const existing = roundStartsByMap.get(rs.MapDataId) ?? []; + existing.push(rs.match_time); + roundStartsByMap.set(rs.MapDataId, existing); + } + + const ourSwapsByMap = new Map(); + const opponentSwapsByMap = new Map(); + + for (const swap of allHeroSwaps) { + if (!swap.MapDataId) continue; + const ourTeam = ourTeamNameByMap.get(swap.MapDataId); + if (!ourTeam) continue; + + if (swap.player_team === ourTeam) { + const existing = ourSwapsByMap.get(swap.MapDataId) ?? []; + existing.push(swap); + ourSwapsByMap.set(swap.MapDataId, existing); + } else { + const existing = opponentSwapsByMap.get(swap.MapDataId) ?? []; + existing.push(swap); + opponentSwapsByMap.set(swap.MapDataId, existing); + } + } + + const filteredOurByMap = new Map(); + for (const [mapId, swaps] of ourSwapsByMap) { + const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; + const filtered = filterUtilityRoundStartSwaps(swaps, rsTimesForMap); + if (filtered.length > 0) filteredOurByMap.set(mapId, filtered); + } + + const filteredOppByMap = new Map(); + for (const [mapId, swaps] of opponentSwapsByMap) { + const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; + const filtered = filterUtilityRoundStartSwaps(swaps, rsTimesForMap); + if (filtered.length > 0) filteredOppByMap.set(mapId, filtered); + } + + const allOurSwaps: SwapRecord[] = []; + for (const swaps of filteredOurByMap.values()) allOurSwaps.push(...swaps); + const allOppSwaps: SwapRecord[] = []; + for (const swaps of filteredOppByMap.values()) allOppSwaps.push(...swaps); + + const mapCount = mapIds.length; + const ourSwaps = allOurSwaps.length; + const opponentSwaps = allOppSwaps.length; + const mapsWithOurSwaps = filteredOurByMap.size; + const mapsWithoutOurSwaps = mapCount - mapsWithOurSwaps; + + const mapResultLookup = new Map(); + for (const mr of mapResults) mapResultLookup.set(mr.mapId, mr); + + let noSwapWins = 0; + let noSwapLosses = 0; + let swapWins = 0; + let swapLosses = 0; + + for (const mapId of mapIds) { + const result = mapResultLookup.get(mapId); + if (!result || result.winner === "draw") continue; + const hasSwaps = filteredOurByMap.has(mapId); + const isWin = result.winner === "our_team"; + + if (hasSwaps) { + if (isWin) swapWins++; + else swapLosses++; + } else { + if (isWin) noSwapWins++; + else noSwapLosses++; + } + } + + const noSwapTotal = noSwapWins + noSwapLosses; + const swapTotal = swapWins + swapLosses; + + let totalHeroTimeBeforeSwap = 0; + for (const swap of allOurSwaps) + totalHeroTimeBeforeSwap += swap.hero_time_played; + + function findTopSwap(swaps: SwapRecord[]) { + const counts = new Map< + string, + { from: string; to: string; count: number } + >(); + for (const swap of swaps) { + const key = `${swap.previous_hero}->${swap.player_hero}`; + const existing = counts.get(key); + if (existing) existing.count++; + else + counts.set(key, { + from: swap.previous_hero, + to: swap.player_hero, + count: 1, + }); + } + let best: { from: string; to: string; count: number } | null = null; + for (const entry of counts.values()) { + if (!best || entry.count > best.count) best = entry; + } + return best; + } + + const playerSwapCounts = new Map< + string, + { count: number; maps: Set } + >(); + for (const swap of allOurSwaps) { + if (!swap.MapDataId) continue; + const entry = playerSwapCounts.get(swap.player_name) ?? { + count: 0, + maps: new Set(), + }; + entry.count++; + entry.maps.add(swap.MapDataId); + playerSwapCounts.set(swap.player_name, entry); + } + + let topSwapper: ScrimSwapAnalysis["topSwapper"] = null; + for (const [playerName, entry] of playerSwapCounts) { + if (!topSwapper || entry.count > topSwapper.count) { + topSwapper = { + playerName, + count: entry.count, + mapsCount: entry.maps.size, + }; + } + } + + const swapCountByMap = new Map(); + for (const mapId of mapIds) { + swapCountByMap.set(mapId, filteredOurByMap.get(mapId)?.length ?? 0); + } + + const countBuckets: { label: string; min: number; max: number }[] = [ + { label: "0 swaps", min: 0, max: 0 }, + { label: "1 swap", min: 1, max: 1 }, + { label: "2 swaps", min: 2, max: 2 }, + { label: "3+ swaps", min: 3, max: Infinity }, + ]; + + const winrateBySwapCount: SwapWinrateBucket[] = countBuckets.map((bucket) => { + let wins = 0; + let losses = 0; + + for (const mapId of mapIds) { + const result = mapResultLookup.get(mapId); + if (!result || result.winner === "draw") continue; + const count = swapCountByMap.get(mapId) ?? 0; + if (count >= bucket.min && count <= bucket.max) { + if (result.winner === "our_team") wins++; + else losses++; + } + } + + const total = wins + losses; + return { + label: bucket.label, + wins, + losses, + winrate: total > 0 ? (wins / total) * 100 : 0, + totalMaps: total, + }; + }); + + const matchEndTimeMap = new Map(); + for (const me of allMatchEnds) { + if (me.MapDataId) { + const existing = matchEndTimeMap.get(me.MapDataId); + if (!existing || me.match_time > existing) { + matchEndTimeMap.set(me.MapDataId, me.match_time); + } + } + } + + const timingLabels = ["Early (0-33%)", "Mid (33-66%)", "Late (66-100%)"]; + const timingMapSets: { wins: Set; losses: Set }[] = [ + { wins: new Set(), losses: new Set() }, + { wins: new Set(), losses: new Set() }, + { wins: new Set(), losses: new Set() }, + ]; + + for (const swap of allOurSwaps) { + if (!swap.MapDataId) continue; + const totalTime = matchEndTimeMap.get(swap.MapDataId); + if (!totalTime || totalTime <= 0) continue; + + const pct = (swap.match_time / totalTime) * 100; + let timingIndex: number; + if (pct < 33.33) timingIndex = 0; + else if (pct < 66.67) timingIndex = 1; + else timingIndex = 2; + + const result = mapResultLookup.get(swap.MapDataId); + if (!result || result.winner === "draw") continue; + + if (result.winner === "our_team") { + timingMapSets[timingIndex].wins.add(swap.MapDataId); + } else { + timingMapSets[timingIndex].losses.add(swap.MapDataId); + } + } + + const timingOutcomes: SwapTimingOutcome[] = timingLabels.map((label, i) => { + const wins = timingMapSets[i].wins.size; + const losses = timingMapSets[i].losses.size; + const total = wins + losses; + return { + label, + wins, + losses, + winrate: total > 0 ? (wins / total) * 100 : 0, + totalMaps: total, + }; + }); + + return { + ourSwaps, + opponentSwaps, + ourSwapsPerMap: mapCount > 0 ? ourSwaps / mapCount : 0, + opponentSwapsPerMap: mapCount > 0 ? opponentSwaps / mapCount : 0, + mapsWithOurSwaps, + mapsWithoutOurSwaps, + noSwapWinrate: noSwapTotal > 0 ? (noSwapWins / noSwapTotal) * 100 : 0, + noSwapWins, + noSwapLosses, + swapWinrate: swapTotal > 0 ? (swapWins / swapTotal) * 100 : 0, + swapWins, + swapLosses, + avgHeroTimeBeforeSwap: + ourSwaps > 0 ? totalHeroTimeBeforeSwap / ourSwaps : 0, + ourTopSwap: findTopSwap(allOurSwaps), + opponentTopSwap: findTopSwap(allOppSwaps), + topSwapper, + winrateBySwapCount, + timingOutcomes, + }; +} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect< + ScrimOverviewServiceInterface, + never, + TeamSharedDataService | ScrimAbilityTimingService +> = Effect.gen(function* () { + const teamSharedData = yield* TeamSharedDataService; + const scrimAbilityTiming = yield* ScrimAbilityTimingService; + + function getScrimOverview( + scrimId: number, + teamId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { scrimId, teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimId", scrimId); + yield* Effect.annotateCurrentSpan("teamId", teamId); + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { scrimId }, + orderBy: { id: "asc" }, + select: { + id: true, + name: true, + mapData: { select: { id: true } }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchMaps", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.overview.fetchMaps", { + attributes: { scrimId }, + }) + ); + + if (maps.length === 0) { + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + wideEvent.early_return = "no_maps"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } + + const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); + + if (mapDataIds.length === 0) { + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + wideEvent.early_return = "no_map_data"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } + + wideEvent.map_count = maps.length; + wideEvent.map_data_count = mapDataIds.length; + + const teamRoster = yield* teamSharedData.getTeamRoster(teamId).pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchRoster", + cause: error, + }) + ) + ); + + const scrimPlayerNames = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { player_name: true }, + distinct: ["player_name"], + }), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchPlayers", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.overview.fetchRosterAndPlayers", { + attributes: { scrimId, teamId }, + }) + ); + + const teamRosterSet = new Set(teamRoster); + const scrimPlayers = scrimPlayerNames + .map((p) => p.player_name) + .filter((name) => teamRosterSet.has(name)); + + if (scrimPlayers.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_scrim_players"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } + + wideEvent.scrim_player_count = scrimPlayers.length; + + const [ + finalRoundStats, + calculatedStats, + matchStarts, + finalRounds, + captures, + payloadProgressRows, + pointProgressRows, + allKills, + allRezzes, + allUltimates, + allHeroSwaps, + allRoundStarts, + allMatchEnds, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapDataIds)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapDataIds)}) + AND ps."player_name" IN (${Prisma.join(scrimPlayers)}) + `.then((rows) => removeDuplicateRows(rows)), + prisma.calculatedStat.findMany({ + where: { + MapDataId: { in: mapDataIds }, + playerName: { in: scrimPlayers }, + }, + }), + prisma.matchStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { round_number: "desc" }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.kill.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.mercyRez.findMany({ + where: { MapDataId: { in: mapDataIds } }, + }), + prisma.ultimateStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { match_time: "asc" }, + }), + prisma.heroSwap.findMany({ + where: { + MapDataId: { in: mapDataIds }, + match_time: { not: 0 }, + }, + select: { + id: true, + match_time: true, + player_team: true, + player_name: true, + player_hero: true, + previous_hero: true, + hero_time_played: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.roundStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + prisma.matchEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + ]), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchAllData", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.overview.fetchAllData", { + attributes: { scrimId, teamId, mapCount: mapDataIds.length }, + }) + ); + + if (finalRoundStats.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_final_round_stats"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } + + // Build lookup maps + const matchStartByMapId = new Map(); + for (const ms of matchStarts) { + if (ms.MapDataId) matchStartByMapId.set(ms.MapDataId, ms); + } + + const finalRoundByMapId = new Map(); + for (const round of finalRounds) { + if (round.MapDataId && !finalRoundByMapId.has(round.MapDataId)) { + finalRoundByMapId.set(round.MapDataId, round); + } + } + + const capturesByMapAndTeam = new Map< + number, + Map + >(); + for (const capture of captures) { + if (!capture.MapDataId) continue; + if (!capturesByMapAndTeam.has(capture.MapDataId)) { + capturesByMapAndTeam.set( + capture.MapDataId, + new Map() + ); + } + const mapCaptures = capturesByMapAndTeam.get(capture.MapDataId)!; + if (!mapCaptures.has(capture.capturing_team)) { + mapCaptures.set(capture.capturing_team, []); + } + mapCaptures.get(capture.capturing_team)!.push(capture); + } + + const payloadProgressByMapAndTeam = new Map< + number, + Map + >(); + for (const progressRow of payloadProgressRows) { + if (!progressRow.MapDataId) continue; + if (!payloadProgressByMapAndTeam.has(progressRow.MapDataId)) { + payloadProgressByMapAndTeam.set( + progressRow.MapDataId, + new Map() + ); + } + const mapProgressRows = payloadProgressByMapAndTeam.get( + progressRow.MapDataId + )!; + if (!mapProgressRows.has(progressRow.capturing_team)) { + mapProgressRows.set(progressRow.capturing_team, []); + } + mapProgressRows.get(progressRow.capturing_team)!.push(progressRow); + } + + const pointProgressByMapAndTeam = new Map< + number, + Map + >(); + for (const progressRow of pointProgressRows) { + if (!progressRow.MapDataId) continue; + if (!pointProgressByMapAndTeam.has(progressRow.MapDataId)) { + pointProgressByMapAndTeam.set( + progressRow.MapDataId, + new Map() + ); + } + const mapProgressRows = pointProgressByMapAndTeam.get( + progressRow.MapDataId + )!; + if (!mapProgressRows.has(progressRow.capturing_team)) { + mapProgressRows.set(progressRow.capturing_team, []); + } + mapProgressRows.get(progressRow.capturing_team)!.push(progressRow); + } + + const killsByMap = new Map(); + for (const kill of allKills) { + if (!kill.MapDataId) continue; + if (!killsByMap.has(kill.MapDataId)) { + killsByMap.set(kill.MapDataId, []); + } + killsByMap.get(kill.MapDataId)!.push(kill); + } + for (const rez of allRezzes) { + if (!rez.MapDataId) continue; + if (!killsByMap.has(rez.MapDataId)) { + killsByMap.set(rez.MapDataId, []); + } + killsByMap.get(rez.MapDataId)!.push(mercyRezToKillEvent(rez)); + } + + const fightsByMap = new Map(); + for (const [mapId, events] of killsByMap.entries()) { + events.sort((a, b) => a.match_time - b.match_time); + fightsByMap.set(mapId, groupEventsIntoFights(events)); + } + + const mapIdToMapDataId = new Map(); + for (const map of maps) { + if (map.mapData[0]) { + mapIdToMapDataId.set(map.id, map.mapData[0].id); + } + } + + const fightsByMapWithUlts = new Map(); + for (const mdId of mapDataIds) { + const kills = killsByMap.get(mdId) ?? []; + const mapUlts = allUltimates.filter((u) => u.MapDataId === mdId); + const merged: Kill[] = [ + ...kills, + ...mapUlts.map(ultimateStartToKillEvent), + ]; + merged.sort((a, b) => a.match_time - b.match_time); + fightsByMapWithUlts.set(mdId, groupEventsIntoFights(merged)); + } + + type MapFirstDeathLookup = { + fightCount: number; + overallFirstDeaths: Map; + teamFirstDeaths: Map>; + }; + const firstDeathLookup = new Map(); + for (const [mapId, fights] of fightsByMap.entries()) { + const overallFirstDeaths = new Map(); + const teamFirstDeaths = new Map>(); + + for (const fight of fights) { + const overallVictim = fight.kills[0]?.victim_name; + if (overallVictim) { + overallFirstDeaths.set( + overallVictim, + (overallFirstDeaths.get(overallVictim) ?? 0) + 1 + ); + } + + const teamsSeen = new Set(); + for (const kill of fight.kills) { + if (kill.event_type !== "kill" || teamsSeen.has(kill.victim_team)) + continue; + teamsSeen.add(kill.victim_team); + if (!teamFirstDeaths.has(kill.victim_team)) { + teamFirstDeaths.set(kill.victim_team, new Map()); + } + const playerCounts = teamFirstDeaths.get(kill.victim_team)!; + playerCounts.set( + kill.victim_name, + (playerCounts.get(kill.victim_name) ?? 0) + 1 + ); + } + } + + firstDeathLookup.set(mapId, { + fightCount: fights.length, + overallFirstDeaths, + teamFirstDeaths, + }); + } + + const ourTeamNameByMap = getMapTeamNames(finalRoundStats); + + const fightAnalysis = computeScrimFightAnalysis( + mapDataIds, + killsByMap, + allUltimates, + ourTeamNameByMap + ); + + const ultAnalysis = computeScrimUltAnalysis( + allUltimates, + fightsByMapWithUlts, + ourTeamNameByMap, + calculatedStats, + scrimPlayers + ); + + // Determine W/L/D for each map + const mapResults: MapResult[] = []; + let wins = 0; + let losses = 0; + let draws = 0; + + for (const map of maps) { + const mdId = mapIdToMapDataId.get(map.id); + const matchStart = mdId ? (matchStartByMapId.get(mdId) ?? null) : null; + const finalRound = mdId ? (finalRoundByMapId.get(mdId) ?? null) : null; + const mapCaptures = mdId ? capturesByMapAndTeam.get(mdId) : undefined; + const team1Caps = matchStart?.team_1_name + ? (mapCaptures?.get(matchStart.team_1_name) ?? []) + : []; + const team2Caps = matchStart?.team_2_name + ? (mapCaptures?.get(matchStart.team_2_name) ?? []) + : []; + const mapPayloadProgress = mdId + ? payloadProgressByMapAndTeam.get(mdId) + : undefined; + const team1PayloadProgress = matchStart?.team_1_name + ? (mapPayloadProgress?.get(matchStart.team_1_name) ?? []) + : []; + const team2PayloadProgress = matchStart?.team_2_name + ? (mapPayloadProgress?.get(matchStart.team_2_name) ?? []) + : []; + const mapPointProgress = mdId + ? pointProgressByMapAndTeam.get(mdId) + : undefined; + const team1PointProgress = matchStart?.team_1_name + ? (mapPointProgress?.get(matchStart.team_1_name) ?? []) + : []; + const team2PointProgress = matchStart?.team_2_name + ? (mapPointProgress?.get(matchStart.team_2_name) ?? []) + : []; + + const winnerName = calculateWinner({ + matchDetails: matchStart, + finalRound, + team1Captures: team1Caps, + team2Captures: team2Caps, + team1PayloadProgress, + team2PayloadProgress, + team1PointProgress, + team2PointProgress, + }); + + const ourTeamName = mdId ? (ourTeamNameByMap.get(mdId) ?? null) : null; + let winner: MapResult["winner"]; + + if (winnerName === "N/A" || !ourTeamName) { + winner = "draw"; + draws++; + } else if (winnerName === ourTeamName) { + winner = "our_team"; + wins++; + } else { + winner = "opponent"; + losses++; + } + + mapResults.push({ mapId: map.id, mapName: map.name, winner }); + } + + const playerRowsMap = new Map(); + for (const row of finalRoundStats) { + if (!playerRowsMap.has(row.player_name)) { + playerRowsMap.set(row.player_name, []); + } + playerRowsMap.get(row.player_name)!.push(row); + } + + const playerCalculatedMap = new Map(); + for (const row of calculatedStats) { + if (!playerCalculatedMap.has(row.playerName)) { + playerCalculatedMap.set(row.playerName, []); + } + playerCalculatedMap.get(row.playerName)!.push(row); + } + + const basePlayers: PlayerScrimPerformance[] = []; + for (const playerName of scrimPlayers) { + const playerRows = playerRowsMap.get(playerName) ?? []; + if (playerRows.length === 0) continue; + const playerCalcRows = playerCalculatedMap.get(playerName) ?? []; + + const rowsByMap = new Map(); + for (const row of playerRows) { + if (!row.MapDataId) continue; + if (!rowsByMap.has(row.MapDataId)) { + rowsByMap.set(row.MapDataId, []); + } + rowsByMap.get(row.MapDataId)!.push(row); + } + + const calcByMap = new Map(); + for (const row of playerCalcRows) { + if (!calcByMap.has(row.MapDataId)) { + calcByMap.set(row.MapDataId, []); + } + calcByMap.get(row.MapDataId)!.push(row); + } + + const playerTeam = playerRows[0].player_team; + + const perMapStats: PlayerStat[] = []; + const perMapCalculatedStats: CalculatedStat[][] = []; + const perMapPerformance: PlayerMapPerformance[] = []; + let totalFights = 0; + let totalFirstDeaths = 0; + let totalTeamFirstDeaths = 0; + + for (let i = 0; i < maps.length; i++) { + const mdId = mapIdToMapDataId.get(maps[i].id); + if (!mdId) continue; + const rows = rowsByMap.get(mdId) ?? []; + if (rows.length === 0) continue; + const aggregatedRow = aggregateRowsToPlayerStat(rows); + if (!aggregatedRow) continue; + perMapStats.push(aggregatedRow); + perMapCalculatedStats.push(calcByMap.get(mdId) ?? []); + + const fdLookup = firstDeathLookup.get(mdId); + const mapFightCount = fdLookup?.fightCount ?? 0; + const mapFirstDeaths = + fdLookup?.overallFirstDeaths.get(playerName) ?? 0; + const mapTeamFirstDeaths = + fdLookup?.teamFirstDeaths.get(playerTeam)?.get(playerName) ?? 0; + + totalFights += mapFightCount; + totalFirstDeaths += mapFirstDeaths; + totalTeamFirstDeaths += mapTeamFirstDeaths; + + const time = aggregatedRow.hero_time_played; + perMapPerformance.push({ + mapName: maps[i].name, + mapIndex: i, + kdRatio: + aggregatedRow.deaths > 0 + ? aggregatedRow.eliminations / aggregatedRow.deaths + : aggregatedRow.eliminations, + eliminationsPer10: + time > 0 ? (aggregatedRow.eliminations / time) * 600 : 0, + heroDamagePer10: + time > 0 ? (aggregatedRow.hero_damage_dealt / time) * 600 : 0, + healingDealtPer10: + time > 0 ? (aggregatedRow.healing_dealt / time) * 600 : 0, + firstDeathRate: + mapFightCount > 0 ? (mapFirstDeaths / mapFightCount) * 100 : 0, + teamFirstDeathRate: + mapFightCount > 0 + ? (mapTeamFirstDeaths / mapFightCount) * 100 + : 0, + }); + } + + const aggregated = aggregatePlayerStats( + playerRows, + playerCalcRows, + perMapStats, + perMapCalculatedStats + ); + + const heroTimeMap = new Map(); + for (const row of playerRows) { + heroTimeMap.set( + row.player_hero, + (heroTimeMap.get(row.player_hero) ?? 0) + row.hero_time_played + ); + } + const heroes = Array.from(heroTimeMap.keys()) as HeroName[]; + if (heroes.length === 0) continue; + + let primaryHero: HeroName = heroes[0] ?? "Ana"; + let maxTime = -1; + for (const [hero, time] of heroTimeMap.entries()) { + if (time > maxTime) { + maxTime = time; + primaryHero = hero as HeroName; + } + } + + const trendData = + perMapStats.length >= 3 + ? calculateTrends(perMapStats, perMapCalculatedStats) + : undefined; + const trend = determineTrend(trendData); + const rowId = playerRows.reduce( + (lowest, row) => (row.id < lowest ? row.id : lowest), + playerRows[0].id + ); + + const kdRatio = + aggregated.deaths > 0 + ? aggregated.eliminations / aggregated.deaths + : aggregated.eliminations; + + basePlayers.push({ + playerKey: `${playerName}:${rowId}`, + playerName, + primaryHero, + heroes, + mapsPlayed: perMapStats.length, + eliminations: aggregated.eliminations, + deaths: aggregated.deaths, + heroDamageDealt: aggregated.heroDamageDealt, + healingDealt: aggregated.healingDealt, + heroTimePlayed: aggregated.heroTimePlayed, + kdRatio, + eliminationsPer10: aggregated.eliminationsPer10, + deathsPer10: aggregated.deathsPer10, + heroDamagePer10: aggregated.heroDamagePer10, + healingDealtPer10: aggregated.healingDealtPer10, + firstDeathCount: totalFirstDeaths, + firstDeathRate: + totalFights > 0 ? (totalFirstDeaths / totalFights) * 100 : 0, + teamFirstDeathCount: totalTeamFirstDeaths, + teamFirstDeathRate: + totalFights > 0 ? (totalTeamFirstDeaths / totalFights) * 100 : 0, + perMapPerformance, + zScores: {}, + outliers: [], + trend, + trendData, + } satisfies PlayerScrimPerformance); + } + + const uniqueHeroes = new Set(); + for (const player of basePlayers) { + if (player.heroTimePlayed >= 300) { + uniqueHeroes.add(player.primaryHero); + } + } + + const baselineMap = yield* Effect.tryPromise({ + try: () => + getBatchedStatDistributionBaselines(Array.from(uniqueHeroes)), + catch: (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchBaselines", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.overview.fetchBaselines", { + attributes: { heroCount: uniqueHeroes.size }, + }) + ); + + const teamPlayers: PlayerScrimPerformance[] = basePlayers.map( + (player) => { + if (player.heroTimePlayed < 300) return player; + const zScores: Partial> = {}; + const outliers: ScrimOutlier[] = []; + + const comparisonStats = statsForHeroRole( + player.primaryHero, + player.heroDamageDealt, + player.healingDealt, + 0, + player.deaths, + player.eliminations + ); + + for (const statDef of comparisonStats) { + const baseline = baselineMap.get( + createBaselineKey(player.primaryHero, statDef.stat) + ); + if (!baseline) continue; + + const per10Value = (statDef.value / player.heroTimePlayed) * 600; + const zScore = calculateZScoreFromBaseline({ + baseline, + stat: statDef.stat, + per10Value, + }); + if (zScore === null) continue; + const percentile = Math.round(normalCdf(zScore) * 1000) / 10; + + zScores[statDef.stat] = zScore; + if (Math.abs(zScore) >= 2) { + outliers.push({ + stat: statDef.stat, + zScore, + percentile, + direction: zScore > 0 ? "high" : "low", + label: statLabelFor(statDef.stat), + }); + } + } + + outliers.sort((a, b) => Math.abs(b.zScore) - Math.abs(a.zScore)); + return { ...player, zScores, outliers }; + } + ); + + teamPlayers.sort( + (a, b) => + b.mapsPlayed - a.mapsPlayed || + a.playerName.localeCompare(b.playerName) + ); + + const teamTotals = teamPlayers.reduce( + (acc, p) => ({ + eliminations: acc.eliminations + p.eliminations, + deaths: acc.deaths + p.deaths, + heroDamage: acc.heroDamage + p.heroDamageDealt, + healing: acc.healing + p.healingDealt, + kdRatio: 0, + }), + { + eliminations: 0, + deaths: 0, + heroDamage: 0, + healing: 0, + kdRatio: 0, + } + ); + teamTotals.kdRatio = + teamTotals.deaths > 0 + ? teamTotals.eliminations / teamTotals.deaths + : teamTotals.eliminations; + + const insights = generateInsights(teamPlayers); + + const firstMapDataId = mapDataIds[0]; + const firstOurTeamName = ourTeamNameByMap.get(firstMapDataId) ?? ""; + const firstMatchStart = matchStartByMapId.get(firstMapDataId); + const opponentTeamName = + firstMatchStart?.team_1_name === firstOurTeamName + ? (firstMatchStart?.team_2_name ?? "Opponent") + : (firstMatchStart?.team_1_name ?? "Opponent"); + + const swapAnalysis = computeScrimSwapAnalysis( + allHeroSwaps as SwapRecord[], + allRoundStarts, + allMatchEnds, + mapDataIds, + ourTeamNameByMap, + mapResults + ); + + const abilityTimingAnalysis = yield* scrimAbilityTiming + .getScrimAbilityTiming(scrimId, teamId) + .pipe( + Effect.mapError( + (error) => + new ScrimQueryError({ + operation: "getScrimOverview.fetchAbilityTiming", + cause: error, + }) + ), + Effect.withSpan("scrim.overview.fetchAbilityTiming", { + attributes: { scrimId, teamId }, + }) + ); + + wideEvent.wins = wins; + wideEvent.losses = losses; + wideEvent.draws = draws; + wideEvent.team_player_count = teamPlayers.length; + wideEvent.insight_count = insights.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimOverviewSuccessTotal); + + return { + mapCount: maps.length, + wins, + losses, + draws, + ourTeamName: firstOurTeamName, + opponentTeamName, + mapResults, + teamPlayers, + insights, + teamTotals, + fightAnalysis, + ultAnalysis, + swapAnalysis, + abilityTimingAnalysis, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(scrimOverviewErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getScrimOverview") + : Effect.logInfo("scrim.getScrimOverview"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(scrimOverviewDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("scrim.getScrimOverview") + ); + } + + function overviewCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + + const overviewCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimId, teamId } = JSON.parse(key) as { + scrimId: number; + teamId: number; + }; + return getScrimOverview(scrimId, teamId).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + return { + getScrimOverview: (scrimId: number, teamId: number) => + overviewCache + .get(overviewCacheKeyOf(scrimId, teamId)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + } satisfies ScrimOverviewServiceInterface; +}); + +export const ScrimOverviewServiceLive = Layer.effect( + ScrimOverviewService, + make +).pipe( + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(ScrimAbilityTimingServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/scrim/scrim-service.ts b/src/data/scrim/scrim-service.ts new file mode 100644 index 000000000..46974cbbb --- /dev/null +++ b/src/data/scrim/scrim-service.ts @@ -0,0 +1,1100 @@ +import { + buildCapturesMaps, + buildMatchStartMap, + buildProgressMaps, +} from "@/data/team/shared-core"; +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { removeDuplicateRows } from "@/lib/utils"; +import { calculateWinner } from "@/lib/winrate"; +import { heroPriority, heroRoleMapping, type HeroName } from "@/types/heroes"; +import { + Prisma, + type Kill, + type PlayerStat, + type RoundEnd, + type Scrim, +} from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScrimQueryError } from "./errors"; +import { + scrimCacheMissTotal, + scrimCacheRequestTotal, + scrimGetAllDeathsForPlayerDuration, + scrimGetAllDeathsForPlayerErrorTotal, + scrimGetAllDeathsForPlayerSuccessTotal, + scrimGetAllKillsForPlayerDuration, + scrimGetAllKillsForPlayerErrorTotal, + scrimGetAllKillsForPlayerSuccessTotal, + scrimGetAllMapWinratesForPlayerDuration, + scrimGetAllMapWinratesForPlayerErrorTotal, + scrimGetAllMapWinratesForPlayerSuccessTotal, + scrimGetAllStatsForPlayerDuration, + scrimGetAllStatsForPlayerErrorTotal, + scrimGetAllStatsForPlayerSuccessTotal, + scrimGetFinalRoundStatsDuration, + scrimGetFinalRoundStatsErrorTotal, + scrimGetFinalRoundStatsForPlayerDuration, + scrimGetFinalRoundStatsForPlayerErrorTotal, + scrimGetFinalRoundStatsForPlayerSuccessTotal, + scrimGetFinalRoundStatsSuccessTotal, + scrimGetScrimDuration, + scrimGetScrimErrorTotal, + scrimGetScrimSuccessTotal, + scrimGetUserViewableScrimsDuration, + scrimGetUserViewableScrimsErrorTotal, + scrimGetUserViewableScrimsSuccessTotal, +} from "./metrics"; + +export type Winrate = { map: string; wins: number; date: Date }[]; + +export type ScrimServiceInterface = { + readonly getScrim: ( + id: number + ) => Effect.Effect; + + readonly getUserViewableScrims: ( + userId: string + ) => Effect.Effect; + + readonly getFinalRoundStats: ( + mapId: number + ) => Effect.Effect; + + readonly getFinalRoundStatsForPlayer: ( + mapId: number, + playerName: string + ) => Effect.Effect; + + readonly getAllStatsForPlayer: ( + scrimIds: number[], + name: string + ) => Effect.Effect; + + readonly getAllKillsForPlayer: ( + scrimIds: number[], + name: string + ) => Effect.Effect; + + readonly getAllDeathsForPlayer: ( + scrimIds: number[], + name: string + ) => Effect.Effect; + + readonly getAllMapWinratesForPlayer: ( + scrimIds: number[], + name: string + ) => Effect.Effect; +}; + +export class ScrimService extends Context.Tag("@app/data/scrim/ScrimService")< + ScrimService, + ScrimServiceInterface +>() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getScrim( + id: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { scrimId: id }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimId", id); + const scrim = yield* Effect.tryPromise({ + try: () => prisma.scrim.findFirst({ where: { id } }), + catch: (error) => + new ScrimQueryError({ + operation: "getScrim", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getScrim.query", { + attributes: { scrimId: id }, + }) + ); + + wideEvent.found = scrim !== null; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetScrimSuccessTotal); + return scrim; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(scrimGetScrimErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getScrim") + : Effect.logInfo("scrim.getScrim"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(scrimGetScrimDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("scrim.getScrim") + ); + } + + function getUserViewableScrims( + userId: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { userId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userId", userId); + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { + OR: [ + { creatorId: userId }, + { Team: { users: { some: { id: userId } } } }, + ], + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getUserViewableScrims", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getUserViewableScrims.query", { + attributes: { userId }, + }) + ); + + wideEvent.scrim_count = scrims.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetUserViewableScrimsSuccessTotal); + return scrims; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetUserViewableScrimsErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getUserViewableScrims") + : Effect.logInfo("scrim.getUserViewableScrims"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetUserViewableScrimsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getUserViewableScrims") + ); + } + + function getFinalRoundStats( + mapId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new ScrimQueryError({ + operation: "getFinalRoundStats.resolveMapDataId", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getFinalRoundStats.resolveMapDataId", { + attributes: { mapId }, + }) + ); + + wideEvent.mapDataId = mapDataId; + + const rawStats = yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time + FROM + "PlayerStat" + WHERE + "MapDataId" = ${mapDataId} + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time + WHERE + ps."MapDataId" = ${mapDataId}`, + catch: (error) => + new ScrimQueryError({ + operation: "getFinalRoundStats.query", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getFinalRoundStats.query", { + attributes: { mapId, mapDataId }, + }) + ); + + const stats = removeDuplicateRows(rawStats) + .sort((a, b) => a.player_name.localeCompare(b.player_name)) + .sort( + (a, b) => + heroPriority[heroRoleMapping[a.player_hero as HeroName]] - + heroPriority[heroRoleMapping[b.player_hero as HeroName]] + ) + .sort((a, b) => a.player_team.localeCompare(b.player_team)); + + wideEvent.stat_count = stats.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetFinalRoundStatsSuccessTotal); + return stats; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scrimGetFinalRoundStatsErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getFinalRoundStats") + : Effect.logInfo("scrim.getFinalRoundStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetFinalRoundStatsDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getFinalRoundStats") + ); + } + + function getFinalRoundStatsForPlayer( + mapId: number, + playerName: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapId, playerName }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + yield* Effect.annotateCurrentSpan("playerName", playerName); + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new ScrimQueryError({ + operation: "getFinalRoundStatsForPlayer.resolveMapDataId", + cause: error, + }), + }).pipe( + Effect.withSpan( + "scrim.getFinalRoundStatsForPlayer.resolveMapDataId", + { attributes: { mapId } } + ) + ); + + wideEvent.mapDataId = mapDataId; + + const rawStats = yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time + FROM + "PlayerStat" + WHERE + "MapDataId" = ${mapDataId} + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time + WHERE + ps."MapDataId" = ${mapDataId} + AND ps."player_name" = ${playerName}`, + catch: (error) => + new ScrimQueryError({ + operation: "getFinalRoundStatsForPlayer.query", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getFinalRoundStatsForPlayer.query", { + attributes: { mapId, mapDataId, playerName }, + }) + ); + + const stats = removeDuplicateRows(rawStats); + + wideEvent.stat_count = stats.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetFinalRoundStatsForPlayerSuccessTotal); + return stats; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetFinalRoundStatsForPlayerErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getFinalRoundStatsForPlayer") + : Effect.logInfo("scrim.getFinalRoundStatsForPlayer"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetFinalRoundStatsForPlayerDuration( + Effect.succeed(durationMs) + ) + ) + ); + }) + ), + Effect.withSpan("scrim.getFinalRoundStatsForPlayer") + ); + } + + function getAllStatsForPlayer( + scrimIds: number[], + name: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + name, + scrimIdCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimIdCount", scrimIds.length); + yield* Effect.annotateCurrentSpan("name", name); + + if (scrimIds.length === 0) { + wideEvent.outcome = "success"; + wideEvent.stat_count = 0; + yield* Metric.increment(scrimGetAllStatsForPlayerSuccessTotal); + const _empty: PlayerStat[] = []; return _empty; + } + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { + maps: { select: { mapData: { select: { id: true } } } }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllStatsForPlayer.findScrims", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllStatsForPlayer.findScrims", { + attributes: { scrimIdCount: scrimIds.length }, + }) + ); + + const mapDataIdSet = new Set(); + scrims.forEach((scrim) => { + scrim.maps.forEach((map) => { + map.mapData.forEach((md) => mapDataIdSet.add(md.id)); + }); + }); + const mapDataIdArray = Array.from(mapDataIdSet); + wideEvent.mapDataIdCount = mapDataIdArray.length; + + if (mapDataIdArray.length === 0) { + wideEvent.outcome = "success"; + wideEvent.stat_count = 0; + yield* Metric.increment(scrimGetAllStatsForPlayerSuccessTotal); + const _empty: PlayerStat[] = []; return _empty; + } + + const rawStats = yield* Effect.tryPromise({ + try: () => + prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapDataIdArray)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapDataIdArray)}) + AND ps."player_name" ILIKE ${name}`, + catch: (error) => + new ScrimQueryError({ + operation: "getAllStatsForPlayer.query", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllStatsForPlayer.query", { + attributes: { mapDataIdCount: mapDataIdArray.length, name }, + }) + ); + + const stats = removeDuplicateRows(rawStats); + + wideEvent.stat_count = stats.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetAllStatsForPlayerSuccessTotal); + return stats; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetAllStatsForPlayerErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getAllStatsForPlayer") + : Effect.logInfo("scrim.getAllStatsForPlayer"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetAllStatsForPlayerDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getAllStatsForPlayer") + ); + } + + function getAllKillsForPlayer( + scrimIds: number[], + name: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + name, + scrimIdCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimIdCount", scrimIds.length); + yield* Effect.annotateCurrentSpan("name", name); + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { + maps: { select: { mapData: { select: { id: true } } } }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllKillsForPlayer.findScrims", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllKillsForPlayer.findScrims", { + attributes: { scrimIdCount: scrimIds.length }, + }) + ); + + const mapDataIdSet = new Set(); + scrims.forEach((scrim) => { + scrim.maps.forEach((map) => { + map.mapData.forEach((md) => mapDataIdSet.add(md.id)); + }); + }); + const mapDataIdArray = Array.from(mapDataIdSet); + wideEvent.mapDataIdCount = mapDataIdArray.length; + + const kills = yield* Effect.tryPromise({ + try: () => + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIdArray }, + attacker_name: { equals: name, mode: "insensitive" }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllKillsForPlayer.query", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllKillsForPlayer.query", { + attributes: { mapDataIdCount: mapDataIdArray.length, name }, + }) + ); + + wideEvent.kill_count = kills.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetAllKillsForPlayerSuccessTotal); + return kills; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetAllKillsForPlayerErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getAllKillsForPlayer") + : Effect.logInfo("scrim.getAllKillsForPlayer"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetAllKillsForPlayerDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getAllKillsForPlayer") + ); + } + + function getAllDeathsForPlayer( + scrimIds: number[], + name: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + name, + scrimIdCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimIdCount", scrimIds.length); + yield* Effect.annotateCurrentSpan("name", name); + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { + maps: { select: { mapData: { select: { id: true } } } }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllDeathsForPlayer.findScrims", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllDeathsForPlayer.findScrims", { + attributes: { scrimIdCount: scrimIds.length }, + }) + ); + + const mapDataIdSet = new Set(); + scrims.forEach((scrim) => { + scrim.maps.forEach((map) => { + map.mapData.forEach((md) => mapDataIdSet.add(md.id)); + }); + }); + const mapDataIdArray = Array.from(mapDataIdSet); + wideEvent.mapDataIdCount = mapDataIdArray.length; + + const deaths = yield* Effect.tryPromise({ + try: () => + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIdArray }, + victim_name: { equals: name, mode: "insensitive" }, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllDeathsForPlayer.query", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllDeathsForPlayer.query", { + attributes: { mapDataIdCount: mapDataIdArray.length, name }, + }) + ); + + wideEvent.death_count = deaths.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetAllDeathsForPlayerSuccessTotal); + return deaths; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetAllDeathsForPlayerErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getAllDeathsForPlayer") + : Effect.logInfo("scrim.getAllDeathsForPlayer"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetAllDeathsForPlayerDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("scrim.getAllDeathsForPlayer") + ); + } + + function getAllMapWinratesForPlayer( + scrimIds: number[], + name: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + scrimIds, + name, + scrimIdCount: scrimIds.length, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("scrimIdCount", scrimIds.length); + yield* Effect.annotateCurrentSpan("name", name); + + const scrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { id: { in: scrimIds } }, + select: { + maps: { select: { mapData: { select: { id: true } } } }, + date: true, + id: true, + }, + }), + catch: (error) => + new ScrimQueryError({ + operation: "getAllMapWinratesForPlayer.findScrims", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllMapWinratesForPlayer.findScrims", { + attributes: { scrimIdCount: scrimIds.length }, + }) + ); + + const mapDataIdSet = new Set(); + const mapIdToDateMap = new Map(); + scrims.forEach((scrim) => { + scrim.maps.forEach((map) => { + map.mapData.forEach((md) => { + mapDataIdSet.add(md.id); + mapIdToDateMap.set(md.id, scrim.date); + }); + }); + }); + const mapDataIdArray = Array.from(mapDataIdSet); + wideEvent.mapDataIdCount = mapDataIdArray.length; + + if (mapDataIdArray.length === 0) { + wideEvent.outcome = "success"; + wideEvent.win_count = 0; + yield* Metric.increment(scrimGetAllMapWinratesForPlayerSuccessTotal); + const _empty: Winrate = []; return _empty; + } + + const [ + matchStarts, + allFinalRounds, + captures, + payloadProgresses, + pointProgresses, + playerStats, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.matchStart.findMany({ + where: { MapDataId: { in: mapDataIdArray } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: mapDataIdArray } }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: mapDataIdArray } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: mapDataIdArray } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: mapDataIdArray } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.playerStat.findMany({ + where: { + player_name: { equals: name, mode: "insensitive" }, + MapDataId: { in: mapDataIdArray }, + }, + }), + ]), + catch: (error) => + new ScrimQueryError({ + operation: "getAllMapWinratesForPlayer.parallelQueries", + cause: error, + }), + }).pipe( + Effect.withSpan("scrim.getAllMapWinratesForPlayer.parallelQueries", { + attributes: { mapDataIdCount: mapDataIdArray.length, name }, + }) + ); + + const finalRounds = allFinalRounds.reduce( + (acc, round) => { + if ( + !acc[round.MapDataId!] || + acc[round.MapDataId!].match_time < round.match_time + ) { + acc[round.MapDataId!] = round; + } + return acc; + }, + {} as Record + ); + + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const wins: Winrate = []; + + for (const mapId of mapDataIdArray) { + const playerStat = playerStats.find( + (stat) => stat.MapDataId === mapId + ); + if (!playerStat) continue; + + const matchDetails = matchStarts.find( + (match) => match.MapDataId === mapId + ); + if (!matchDetails) continue; + + const winner = calculateWinner({ + matchDetails, + finalRound: finalRounds[mapId], + team1Captures: team1CapturesMap.get(mapId) ?? [], + team2Captures: team2CapturesMap.get(mapId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapId) ?? [], + }); + + const playerTeam = playerStat?.player_team; + + wins.push({ + map: matchDetails.map_name, + wins: winner === playerTeam ? 1 : 0, + date: mapIdToDateMap.get(mapId) ?? new Date(), + }); + } + + wideEvent.win_count = wins.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scrimGetAllMapWinratesForPlayerSuccessTotal); + return wins; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen( + Metric.increment(scrimGetAllMapWinratesForPlayerErrorTotal) + ) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("scrim.getAllMapWinratesForPlayer") + : Effect.logInfo("scrim.getAllMapWinratesForPlayer"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scrimGetAllMapWinratesForPlayerDuration( + Effect.succeed(durationMs) + ) + ) + ); + }) + ), + Effect.withSpan("scrim.getAllMapWinratesForPlayer") + ); + } + + const scrimCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (id: number) => + getScrim(id).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ), + }); + + const userViewableScrimsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (userId: string) => + getUserViewableScrims(userId).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ), + }); + + const finalRoundStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapId: number) => + getFinalRoundStats(mapId).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ), + }); + + function finalRoundStatsForPlayerCacheKeyOf( + mapId: number, + playerName: string + ) { return JSON.stringify({ mapId, playerName }); } + + const finalRoundStatsForPlayerCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { mapId, playerName } = JSON.parse(key) as { + mapId: number; + playerName: string; + }; + return getFinalRoundStatsForPlayer(mapId, playerName).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function allStatsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + + const allStatsForPlayerCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimIds, name } = JSON.parse(key) as { + scrimIds: number[]; + name: string; + }; + return getAllStatsForPlayer(scrimIds, name).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function allKillsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + + const allKillsForPlayerCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimIds, name } = JSON.parse(key) as { + scrimIds: number[]; + name: string; + }; + return getAllKillsForPlayer(scrimIds, name).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function allDeathsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + + const allDeathsForPlayerCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimIds, name } = JSON.parse(key) as { + scrimIds: number[]; + name: string; + }; + return getAllDeathsForPlayer(scrimIds, name).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + function allMapWinratesForPlayerCacheKeyOf( + scrimIds: number[], + name: string + ) { return JSON.stringify({ scrimIds, name }); } + + const allMapWinratesForPlayerCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const { scrimIds, name } = JSON.parse(key) as { + scrimIds: number[]; + name: string; + }; + return getAllMapWinratesForPlayer(scrimIds, name).pipe( + Effect.tap(() => Metric.increment(scrimCacheMissTotal)) + ); + }, + }); + + return { + getScrim: (id: number) => + scrimCache + .get(id) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getUserViewableScrims: (userId: string) => + userViewableScrimsCache + .get(userId) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getFinalRoundStats: (mapId: number) => + finalRoundStatsCache + .get(mapId) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getFinalRoundStatsForPlayer: (mapId: number, playerName: string) => + finalRoundStatsForPlayerCache + .get(finalRoundStatsForPlayerCacheKeyOf(mapId, playerName)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getAllStatsForPlayer: (scrimIds: number[], name: string) => + allStatsForPlayerCache + .get(allStatsForPlayerCacheKeyOf(scrimIds, name)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getAllKillsForPlayer: (scrimIds: number[], name: string) => + allKillsForPlayerCache + .get(allKillsForPlayerCacheKeyOf(scrimIds, name)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getAllDeathsForPlayer: (scrimIds: number[], name: string) => + allDeathsForPlayerCache + .get(allDeathsForPlayerCacheKeyOf(scrimIds, name)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + getAllMapWinratesForPlayer: (scrimIds: number[], name: string) => + allMapWinratesForPlayerCache + .get(allMapWinratesForPlayerCacheKeyOf(scrimIds, name)) + .pipe(Effect.tap(() => Metric.increment(scrimCacheRequestTotal))), + } satisfies ScrimServiceInterface; + } +); + +export const ScrimServiceLive = Layer.effect(ScrimService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/scrim/types.ts b/src/data/scrim/types.ts new file mode 100644 index 000000000..bfd4c06e3 --- /dev/null +++ b/src/data/scrim/types.ts @@ -0,0 +1,323 @@ +import type { TrendsAnalysis } from "@/data/comparison/types"; +import type { + SwapTimingOutcome, + SwapWinrateBucket, +} from "@/data/team/hero-swap-service"; +import type { ValidStatColumn } from "@/lib/stat-percentiles"; +import type { HeroName, RoleName } from "@/types/heroes"; +import type { MapType } from "@prisma/client"; +import { Schema as S } from "effect"; +import type { PlayerUltComparison, SubroleUltTiming } from "./ult-helpers"; + +export type FightPhase = "pre-fight" | "early" | "mid" | "late" | "cleanup"; + +export type AbilityPhaseStats = { + fights: number; + wins: number; + losses: number; + winrate: number; +}; + +export type AbilityTimingRow = { + heroName: string; + abilityName: string; + abilitySlot: 1 | 2; + impactRating: "high" | "critical"; + phases: Record; + overallWinrate: number; + totalFights: number; +}; + +export type AbilityTimingOutlier = { + heroName: string; + abilityName: string; + phase: FightPhase; + phaseWinrate: number; + overallWinrate: number; + deviation: number; + bestPhase: FightPhase; + bestPhaseWinrate: number; + type: "positive" | "negative"; +}; + +export type AbilityTimingAnalysis = { + rows: AbilityTimingRow[]; + outliers: AbilityTimingOutlier[]; +}; + +export type FightAbilityEvent = { + time: number; + hero: string; + ability: string; + abilitySlot: 1 | 2; + team: "ours" | "enemy"; + phase: FightPhase; +}; + +export type FightKillEvent = { + time: number; + attacker: string; + attackerHero: string; + victim: string; + victimHero: string; + attackerSide: "ours" | "enemy"; +}; + +export type FightTimeline = { + fightNumber: number; + startTime: number; + endTime: number; + duration: number; + outcome: "win" | "loss"; + kills: FightKillEvent[]; + abilityUses: FightAbilityEvent[]; +}; + +export type ScrimFightTimelines = { + fights: FightTimeline[]; + ourTeamName: string; +}; + +export type MapAbilityTimingAnalysis = { + team1: AbilityTimingAnalysis; + team2: AbilityTimingAnalysis; +}; + +export type ScrimMapResult = { + mapName: string; + mapType: MapType; + scrimDate: Date; + opponentWon: boolean; + source: "scrim"; +}; + +export type ScrimHeroBan = { + mapName: string; + mapType: MapType; + scrimDate: Date; + opponentWon: boolean; + opponentBans: string[]; + source: "scrim"; +}; + +export type ScrimPlayerStat = { + playerName: string; + hero: string; + role: "Tank" | "Damage" | "Support"; + timePlayed: number; + eliminations: number; + deaths: number; + source: "scrim"; +}; + +export type ScrimOutlier = { + stat: ValidStatColumn; + zScore: number; + percentile: number; + direction: "high" | "low"; + label: string; +}; + +export type PlayerMapPerformance = { + mapName: string; + mapIndex: number; + kdRatio: number; + eliminationsPer10: number; + heroDamagePer10: number; + healingDealtPer10: number; + firstDeathRate: number; + teamFirstDeathRate: number; +}; + +export type PlayerScrimPerformance = { + playerKey: string; + playerName: string; + primaryHero: HeroName; + heroes: HeroName[]; + mapsPlayed: number; + eliminations: number; + deaths: number; + heroDamageDealt: number; + healingDealt: number; + heroTimePlayed: number; + kdRatio: number; + eliminationsPer10: number; + deathsPer10: number; + heroDamagePer10: number; + healingDealtPer10: number; + firstDeathCount: number; + firstDeathRate: number; + teamFirstDeathCount: number; + teamFirstDeathRate: number; + perMapPerformance: PlayerMapPerformance[]; + zScores: Partial>; + outliers: ScrimOutlier[]; + trend: "improving" | "stable" | "declining"; + trendData?: TrendsAnalysis; +}; + +export type ScrimInsight = { + type: + | "mvp" + | "most_improved" + | "most_declined" + | "outlier_positive" + | "outlier_negative"; + headline: string; + playerName?: string; +}; + +export type MapResult = { + mapId: number; + mapName: string; + winner: "our_team" | "opponent" | "draw"; +}; + +export type ScrimTeamTotals = { + eliminations: number; + deaths: number; + heroDamage: number; + healing: number; + kdRatio: number; +}; + +export type ScrimFightAnalysis = { + totalFights: number; + fightsWon: number; + fightWinrate: number; + teamFirstDeathCount: number; + teamFirstDeathRate: number; + firstDeathWinrate: number; + firstPickCount: number; + firstPickRate: number; + firstPickWinrate: number; + firstUltCount: number; + firstUltRate: number; + firstUltWinrate: number; + opponentFirstUltCount: number; + opponentFirstUltWinrate: number; +}; + +export type { PlayerUltComparison, SubroleUltTiming }; + +export type ScrimUltRoleBreakdown = { + role: RoleName; + ourCount: number; + opponentCount: number; + ourFirstRate: number; + ourSubroleTimings: SubroleUltTiming[]; + opponentSubroleTimings: SubroleUltTiming[]; +}; + +export type FightInitiatingUlt = { + hero: string; + count: number; + isOurTeam: boolean; +}; + +export type UltEfficiency = { + ultimateEfficiency: number; + avgUltsInWonFights: number; + avgUltsInLostFights: number; + wastedUltimates: number; + totalUltsUsedInFights: number; + fightsWon: number; + fightsLost: number; + dryFights: number; + dryFightWins: number; + dryFightWinrate: number; + dryFightReversals: number; + dryFightReversalRate: number; + nonDryFights: number; + nonDryFightReversals: number; + nonDryFightReversalRate: number; +}; + +export type ScrimUltAnalysis = { + ourUltsUsed: number; + opponentUltsUsed: number; + ultsByRole: ScrimUltRoleBreakdown[]; + topUltUser: { playerName: string; hero: string; count: number } | null; + avgChargeTime: number; + avgHoldTime: number; + playerComparisons: PlayerUltComparison[]; + ourFightInitiations: number; + opponentFightInitiations: number; + fightsWithUlts: number; + ourTopFightInitiator: FightInitiatingUlt | null; + opponentTopFightInitiator: FightInitiatingUlt | null; + ultEfficiency: UltEfficiency; +}; + +export type ScrimSwapAnalysis = { + ourSwaps: number; + opponentSwaps: number; + ourSwapsPerMap: number; + opponentSwapsPerMap: number; + mapsWithOurSwaps: number; + mapsWithoutOurSwaps: number; + noSwapWinrate: number; + noSwapWins: number; + noSwapLosses: number; + swapWinrate: number; + swapWins: number; + swapLosses: number; + avgHeroTimeBeforeSwap: number; + ourTopSwap: { from: string; to: string; count: number } | null; + opponentTopSwap: { from: string; to: string; count: number } | null; + topSwapper: { + playerName: string; + count: number; + mapsCount: number; + } | null; + winrateBySwapCount: SwapWinrateBucket[]; + timingOutcomes: SwapTimingOutcome[]; +}; + +export type ScrimOverviewData = { + mapCount: number; + wins: number; + losses: number; + draws: number; + ourTeamName: string; + opponentTeamName: string; + mapResults: MapResult[]; + teamPlayers: PlayerScrimPerformance[]; + insights: ScrimInsight[]; + teamTotals: ScrimTeamTotals; + fightAnalysis: ScrimFightAnalysis; + ultAnalysis: ScrimUltAnalysis; + swapAnalysis: ScrimSwapAnalysis; + abilityTimingAnalysis: AbilityTimingAnalysis; +}; + +export type { SwapTimingOutcome, SwapWinrateBucket, TrendsAnalysis }; + +export const ScrimIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Scrim ID must be a positive integer" }) +); + +export const MapIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Map ID must be a positive integer" }) +); + +export const TeamIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Team ID must be a positive integer" }) +); + +export const UserIdSchema = S.String.pipe( + S.minLength(1, { message: () => "User ID must not be empty" }) +); + +export const PlayerNameSchema = S.String.pipe( + S.minLength(1, { message: () => "Player name must not be empty" }) +); + +export const OpponentAbbrSchema = S.String.pipe( + S.minLength(1, { + message: () => "Opponent abbreviation must not be empty", + }) +); diff --git a/src/data/scrim/ult-helpers.ts b/src/data/scrim/ult-helpers.ts new file mode 100644 index 000000000..459571e59 --- /dev/null +++ b/src/data/scrim/ult-helpers.ts @@ -0,0 +1,158 @@ +/** + * Shared pure functions and types for ult analysis, used by both + * scrim/overview-service and team/ult-service. + */ +import type { SubroleName } from "@/types/heroes"; +import { + SUBROLE_DISPLAY_NAMES, + SUBROLE_ORDER, + subroleHeroMapping, +} from "@/types/heroes"; + +export type SubroleUltTiming = { + subrole: string; + count: number; + initiation: number; + midfight: number; + late: number; +}; + +export type PlayerUltSummary = { + heroCountMap: Map; + totalCount: number; +}; + +export type SubroleCandidate = { + playerName: string; + primaryHero: string; + possibleSubroles: SubroleName[]; + ultCount: number; +}; + +export type PlayerUltComparison = { + subrole: string; + ourPlayerName: string; + ourHero: string; + ourUltCount: number; + opponentPlayerName: string; + opponentHero: string; + opponentUltCount: number; +}; + +function heroSubroles(hero: string): SubroleName[] { + const result: SubroleName[] = []; + for (const subrole of SUBROLE_ORDER) { + if ((subroleHeroMapping[subrole] as string[]).includes(hero)) { + result.push(subrole); + } + } + return result; +} + +function primaryHeroForPlayerSummary(entry: PlayerUltSummary): string { + let bestHero = ""; + let bestCount = 0; + for (const [hero, count] of entry.heroCountMap) { + if (count > bestCount) { + bestCount = count; + bestHero = hero; + } + } + return bestHero; +} + +/** + * Assigns each player to a unique subrole slot. Players whose hero fits + * fewer subroles are assigned first so they don't lose their only option + * to a more flexible player. This handles overlap (e.g. Tracer appearing + * in both HitscanDamage and FlexDamage). + */ +export function assignPlayersToSubroles( + counts: Map +): Map { + const candidates: SubroleCandidate[] = []; + for (const [name, entry] of counts) { + const hero = primaryHeroForPlayerSummary(entry); + const possible = heroSubroles(hero); + if (possible.length > 0) { + candidates.push({ + playerName: name, + primaryHero: hero, + possibleSubroles: possible, + ultCount: entry.totalCount, + }); + } + } + + candidates.sort( + (a, b) => + a.possibleSubroles.length - b.possibleSubroles.length || + b.ultCount - a.ultCount + ); + + const assigned = new Map(); + const usedPlayers = new Set(); + + for (const candidate of candidates) { + if (usedPlayers.has(candidate.playerName)) continue; + for (const subrole of candidate.possibleSubroles) { + if (!assigned.has(subrole)) { + assigned.set(subrole, candidate); + usedPlayers.add(candidate.playerName); + break; + } + } + } + + // Second pass: try to place any remaining unassigned players by checking + // if the current occupant of a slot could move to an alternative slot. + for (const candidate of candidates) { + if (usedPlayers.has(candidate.playerName)) continue; + for (const subrole of candidate.possibleSubroles) { + const occupant = assigned.get(subrole); + if (!occupant) { + assigned.set(subrole, candidate); + usedPlayers.add(candidate.playerName); + break; + } + const alternativeForOccupant = occupant.possibleSubroles.find( + (alt) => alt !== subrole && !assigned.has(alt) + ); + if (alternativeForOccupant) { + assigned.set(alternativeForOccupant, occupant); + assigned.set(subrole, candidate); + usedPlayers.add(candidate.playerName); + break; + } + } + } + + return assigned; +} + +export function buildPlayerUltComparisons( + ourPlayerUltCounts: Map, + oppPlayerUltCounts: Map +): PlayerUltComparison[] { + const ourBySubrole = assignPlayersToSubroles(ourPlayerUltCounts); + const oppBySubrole = assignPlayersToSubroles(oppPlayerUltCounts); + + const comparisons: PlayerUltComparison[] = []; + for (const subrole of SUBROLE_ORDER) { + const ours = ourBySubrole.get(subrole); + const theirs = oppBySubrole.get(subrole); + if (!ours && !theirs) continue; + + comparisons.push({ + subrole: SUBROLE_DISPLAY_NAMES[subrole], + ourPlayerName: ours?.playerName ?? "", + ourHero: ours?.primaryHero ?? "", + ourUltCount: ours?.ultCount ?? 0, + opponentPlayerName: theirs?.playerName ?? "", + opponentHero: theirs?.primaryHero ?? "", + opponentUltCount: theirs?.ultCount ?? 0, + }); + } + + return comparisons; +} From 23c185e3cd8269a44144db894069fb719e8fa73b Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:16 -0400 Subject: [PATCH 11/42] Add player Effect services Covers player stats, intelligence analysis, scouting profiles, scouting analytics, and performance targets. Intelligence service consumes team, scouting, and scrim services via Effect Context dependencies. --- src/data/player/errors.ts | 72 ++ src/data/player/index.ts | 70 + src/data/player/intelligence-service.ts | 660 ++++++++++ src/data/player/metrics.ts | 187 +++ src/data/player/player-service.ts | 132 ++ src/data/player/scouting-analytics-service.ts | 1150 +++++++++++++++++ src/data/player/scouting-service.ts | 525 ++++++++ src/data/player/targets-service.ts | 498 +++++++ src/data/player/types.ts | 265 ++++ 9 files changed, 3559 insertions(+) create mode 100644 src/data/player/errors.ts create mode 100644 src/data/player/index.ts create mode 100644 src/data/player/intelligence-service.ts create mode 100644 src/data/player/metrics.ts create mode 100644 src/data/player/player-service.ts create mode 100644 src/data/player/scouting-analytics-service.ts create mode 100644 src/data/player/scouting-service.ts create mode 100644 src/data/player/targets-service.ts create mode 100644 src/data/player/types.ts diff --git a/src/data/player/errors.ts b/src/data/player/errors.ts new file mode 100644 index 000000000..46b762937 --- /dev/null +++ b/src/data/player/errors.ts @@ -0,0 +1,72 @@ +import { Schema as S } from "effect"; + +export class PlayerQueryError extends S.TaggedError()( + "PlayerQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Player query failed: ${this.operation}`; + } +} + +export class PlayerNotFoundError extends S.TaggedError()( + "PlayerNotFoundError", + { + identifier: S.String, + } +) { + get message(): string { + return `Player not found: ${this.identifier}`; + } +} + +export class ScoutingQueryError extends S.TaggedError()( + "ScoutingQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Scouting query failed: ${this.operation}`; + } +} + +export class ScoutingAnalyticsQueryError extends S.TaggedError()( + "ScoutingAnalyticsQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Scouting analytics query failed: ${this.operation}`; + } +} + +export class IntelligenceQueryError extends S.TaggedError()( + "IntelligenceQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Player intelligence query failed: ${this.operation}`; + } +} + +export class TargetsQueryError extends S.TaggedError()( + "TargetsQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Targets query failed: ${this.operation}`; + } +} diff --git a/src/data/player/index.ts b/src/data/player/index.ts new file mode 100644 index 000000000..172fcbfb5 --- /dev/null +++ b/src/data/player/index.ts @@ -0,0 +1,70 @@ +export { PlayerService, PlayerServiceLive } from "./player-service"; +export type { PlayerServiceInterface } from "./player-service"; + +export { + IntelligenceService, + IntelligenceServiceLive, +} from "./intelligence-service"; +export type { IntelligenceServiceInterface } from "./intelligence-service"; + +export { ScoutingService, ScoutingServiceLive } from "./scouting-service"; +export type { ScoutingServiceInterface } from "./scouting-service"; + +export { + ScoutingAnalyticsService, + ScoutingAnalyticsServiceLive, +} from "./scouting-analytics-service"; +export type { ScoutingAnalyticsServiceInterface } from "./scouting-analytics-service"; + +export { + TargetsService, + TargetsServiceLive, + calculateTargetProgress, +} from "./targets-service"; +export type { TargetsServiceInterface } from "./targets-service"; + +export { + PlayerQueryError, + PlayerNotFoundError, + ScoutingQueryError, + ScoutingAnalyticsQueryError, + IntelligenceQueryError, + TargetsQueryError, +} from "./errors"; + +export type { + MostPlayedHeroRow, + ScoutingPlayerSummary, + HeroFrequency, + TournamentMatchEntry, + TournamentRecord, + PlayerProfile, + HeroStatZScore, + ScoutingHeroPerformance, + AdvancedMetrics, + KillPatterns, + RoleDistributionEntry, + AccuracyStats, + MapWinrateEntry, + MapTypeWinrateEntry, + CompetitiveMapWinrates, + ScrimMapWinrates, + ScrimData, + InsightItem, + PlayerScoutingAnalytics, + PlayerHeroZScore, + PlayerHeroDepth, + HeroSubstitutionRate, + PlayerVulnerability, + BestPlayerHighlight, + PlayerIntelligence, + ScrimStatPoint, + TargetProgress, +} from "./types"; + +export { + MapIdSchema, + TeamIdSchema, + PlayerNameSchema, + SlugSchema, +} from "./types"; diff --git a/src/data/player/intelligence-service.ts b/src/data/player/intelligence-service.ts new file mode 100644 index 000000000..9517ccfd6 --- /dev/null +++ b/src/data/player/intelligence-service.ts @@ -0,0 +1,660 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { assessConfidence } from "@/lib/confidence"; +import { + hasScrimData, + type DataAvailabilityProfile, +} from "@/lib/data-availability"; +import prisma from "@/lib/prisma"; +import type { HeroName } from "@/types/heroes"; +import { heroRoleMapping } from "@/types/heroes"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { + ScrimOpponentService, + ScrimOpponentServiceLive, +} from "@/data/scrim/opponent-service"; +import { + ScoutingService, + ScoutingServiceLive, +} from "@/data/scouting/scouting-service"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; +import { IntelligenceQueryError } from "./errors"; +import { + playerCacheRequestTotal, + playerCacheMissTotal, + playerIntelligenceQueryDuration, + playerIntelligenceQueryErrorTotal, + playerIntelligenceQuerySuccessTotal, +} from "./metrics"; +import type { + BestPlayerHighlight, + HeroSubstitutionRate, + PlayerHeroDepth, + PlayerHeroZScore, + PlayerIntelligence, + PlayerVulnerability, +} from "./types"; + +type HeroRole = "Tank" | "Damage" | "Support"; + +const MIN_HERO_TIME_SECONDS = 120; +const MIN_MAPS_FOR_HERO = 3; + +type PlayerHeroAgg = { + hero: string; + role: HeroRole; + maps: number; + totalTime: number; + elimsPer10: number; + deathsPer10: number; + damagePer10: number; + healingPer10: number; +}; + +function aggregatePlayerHeroes( + playerName: string, + allPlayerStats: { + player_name: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + eliminations: number; + deaths: number; + hero_damage_dealt: number; + healing_dealt: number; + }[], + teamRosterSet: Set +): PlayerHeroAgg[] { + if (!teamRosterSet.has(playerName)) return []; + + const heroData = new Map< + string, + { + mapIds: Set; + totalTime: number; + totalElims: number; + totalDeaths: number; + totalDamage: number; + totalHealing: number; + } + >(); + + for (const stat of allPlayerStats) { + if (stat.player_name !== playerName || !stat.MapDataId) continue; + if (stat.hero_time_played < MIN_HERO_TIME_SECONDS) continue; + + let data = heroData.get(stat.player_hero); + if (!data) { + data = { + mapIds: new Set(), + totalTime: 0, + totalElims: 0, + totalDeaths: 0, + totalDamage: 0, + totalHealing: 0, + }; + heroData.set(stat.player_hero, data); + } + + if (!data.mapIds.has(stat.MapDataId)) { + data.mapIds.add(stat.MapDataId); + data.totalTime += stat.hero_time_played; + data.totalElims += stat.eliminations; + data.totalDeaths += stat.deaths; + data.totalDamage += stat.hero_damage_dealt; + data.totalHealing += stat.healing_dealt; + } + } + + return Array.from(heroData.entries()) + .filter(([, data]) => data.mapIds.size >= MIN_MAPS_FOR_HERO) + .map(([hero, data]) => { + const timePer10 = data.totalTime > 0 ? 600 / data.totalTime : 0; + const role = heroRoleMapping[hero as HeroName] ?? ("Damage" as HeroRole); + return { + hero, + role, + maps: data.mapIds.size, + totalTime: data.totalTime, + elimsPer10: data.totalElims * timePer10, + deathsPer10: data.totalDeaths * timePer10, + damagePer10: data.totalDamage * timePer10, + healingPer10: data.totalHealing * timePer10, + }; + }) + .sort((a, b) => b.totalTime - a.totalTime); +} + +function computeIntraTeamZScores( + allPlayerHeroes: Map +): Map> { + type StatBucket = { + elims: number[]; + deaths: number[]; + damage: number[]; + healing: number[]; + }; + + const roleStats = new Map(); + + for (const [, heroes] of allPlayerHeroes) { + for (const h of heroes) { + let bucket = roleStats.get(h.role); + if (!bucket) { + bucket = { elims: [], deaths: [], damage: [], healing: [] }; + roleStats.set(h.role, bucket); + } + bucket.elims.push(h.elimsPer10); + bucket.deaths.push(h.deathsPer10); + bucket.damage.push(h.damagePer10); + bucket.healing.push(h.healingPer10); + } + } + + function mean(arr: number[]) { + return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; + } + + function stddev(arr: number[]) { + if (arr.length < 2) return 0; + const m = mean(arr); + return Math.sqrt( + arr.reduce((sum, v) => sum + (v - m) ** 2, 0) / (arr.length - 1) + ); + } + + const zScores = new Map>(); + + for (const [playerName, heroes] of allPlayerHeroes) { + const playerZScores = new Map(); + for (const h of heroes) { + const stats = roleStats.get(h.role); + if (!stats) continue; + + const weights = + h.role === "Tank" + ? { elims: 0.2, deaths: -0.3, damage: 0.2, healing: 0 } + : h.role === "Support" + ? { elims: 0.1, deaths: -0.25, damage: 0.14, healing: 0.35 } + : { elims: 0.3, deaths: -0.2, damage: 0.3, healing: 0 }; + + let composite = 0; + const elimStd = stddev(stats.elims); + const deathStd = stddev(stats.deaths); + const damageStd = stddev(stats.damage); + const healingStd = stddev(stats.healing); + + if (elimStd > 0) + composite += + ((h.elimsPer10 - mean(stats.elims)) / elimStd) * weights.elims; + if (deathStd > 0) + composite += + ((h.deathsPer10 - mean(stats.deaths)) / deathStd) * weights.deaths; + if (damageStd > 0) + composite += + ((h.damagePer10 - mean(stats.damage)) / damageStd) * weights.damage; + if (healingStd > 0) + composite += + ((h.healingPer10 - mean(stats.healing)) / healingStd) * + weights.healing; + + playerZScores.set(h.hero, composite); + } + zScores.set(playerName, playerZScores); + } + + return zScores; +} + +function buildPlayerDepths( + allPlayerHeroes: Map, + zScores: Map> +): PlayerHeroDepth[] { + const depths: PlayerHeroDepth[] = []; + + for (const [playerName, heroes] of allPlayerHeroes) { + if (heroes.length === 0) continue; + + const playerZScores = zScores.get(playerName) ?? new Map(); + const primaryRole = heroes[0].role; + + const heroZScores: PlayerHeroZScore[] = heroes.map((h, i) => ({ + hero: h.hero, + role: h.role, + compositeZScore: playerZScores.get(h.hero) ?? 0, + mapsPlayed: h.maps, + totalTimePlayed: h.totalTime, + isPrimary: i === 0, + })); + + heroZScores.sort((a, b) => b.compositeZScore - a.compositeZScore); + if (heroZScores.length > 0) heroZScores[0].isPrimary = true; + + let primarySecondaryDelta: number | null = null; + if (heroZScores.length >= 2) { + primarySecondaryDelta = + heroZScores[0].compositeZScore - heroZScores[1].compositeZScore; + } + + const totalMaps = heroes.reduce((sum, h) => sum + h.maps, 0); + + depths.push({ + playerName, + role: primaryRole, + heroes: heroZScores, + primarySecondaryDelta, + heroPoolSize: heroZScores.length, + confidence: assessConfidence(totalMaps), + }); + } + + return depths.sort((a, b) => { + const aTop = a.heroes[0]?.compositeZScore ?? 0; + const bTop = b.heroes[0]?.compositeZScore ?? 0; + return bTop - aTop; + }); +} + +function computeSubstitutionRates( + allPlayerHeroes: Map, + allPlayerStats: { + player_name: string; + player_hero: string; + MapDataId: number | null; + hero_time_played: number; + }[], + teamRosterSet: Set, + heroBans: { MapDataId: number; hero: string }[] +): HeroSubstitutionRate[] { + const bansByMap = new Map>(); + for (const ban of heroBans) { + let bans = bansByMap.get(ban.MapDataId); + if (!bans) { + bans = new Set(); + bansByMap.set(ban.MapDataId, bans); + } + bans.add(ban.hero); + } + + const playerMapIds = new Map>(); + const playerHeroMaps = new Map>(); + + for (const stat of allPlayerStats) { + if (!stat.MapDataId || stat.hero_time_played < MIN_HERO_TIME_SECONDS) { + continue; + } + if (!teamRosterSet.has(stat.player_name)) continue; + + let mapIds = playerMapIds.get(stat.player_name); + if (!mapIds) { + mapIds = new Set(); + playerMapIds.set(stat.player_name, mapIds); + } + mapIds.add(stat.MapDataId); + + const heroKey = `${stat.player_name}\0${stat.player_hero}`; + let heroMapIds = playerHeroMaps.get(heroKey); + if (!heroMapIds) { + heroMapIds = new Set(); + playerHeroMaps.set(heroKey, heroMapIds); + } + heroMapIds.add(stat.MapDataId); + } + + const results: HeroSubstitutionRate[] = []; + + for (const [playerName, heroes] of allPlayerHeroes) { + if (!teamRosterSet.has(playerName) || heroes.length === 0) continue; + + const primaryHero = heroes[0].hero; + const mapIds = playerMapIds.get(playerName) ?? new Set(); + const primaryHeroMapIds = + playerHeroMaps.get(`${playerName}\0${primaryHero}`) ?? new Set(); + + const totalMaps = mapIds.size; + let mapsOnPrimary = 0; + let mapsForced = 0; + + for (const mapId of mapIds) { + if (primaryHeroMapIds.has(mapId)) { + mapsOnPrimary++; + } else { + const bans = bansByMap.get(mapId); + if (bans?.has(primaryHero)) { + mapsForced++; + } + } + } + + const substitutionRate = totalMaps > 0 ? (mapsForced / totalMaps) * 100 : 0; + + results.push({ + playerName, + primaryHero, + totalMaps, + mapsOnPrimary, + mapsForced, + substitutionRate, + performanceDelta: null, + }); + } + + return results.sort((a, b) => b.substitutionRate - a.substitutionRate); +} + +function computeVulnerabilities( + playerDepths: PlayerHeroDepth[], + opponentBanCounts: Map, + totalOpponentMaps: number +): PlayerVulnerability[] { + return playerDepths + .filter((p) => p.heroes.length >= 1) + .map((p) => { + const primaryHero = p.heroes[0].hero; + const heroDepthDelta = p.primarySecondaryDelta ?? 0; + const opponentBanCount = opponentBanCounts.get(primaryHero) ?? 0; + const opponentBanRate = + totalOpponentMaps > 0 + ? (opponentBanCount / totalOpponentMaps) * 100 + : 0; + + const vulnerabilityIndex = heroDepthDelta * (opponentBanRate / 100); + + const CRITICAL_THRESHOLD = 0.5; + const HIGH_THRESHOLD = 0.25; + const MODERATE_THRESHOLD = 0.1; + + const riskLevel: PlayerVulnerability["riskLevel"] = + vulnerabilityIndex >= CRITICAL_THRESHOLD + ? "critical" + : vulnerabilityIndex >= HIGH_THRESHOLD + ? "high" + : vulnerabilityIndex >= MODERATE_THRESHOLD + ? "moderate" + : "low"; + + return { + playerName: p.playerName, + role: p.role, + primaryHero, + heroDepthDelta, + opponentBanRate, + opponentBanCount, + vulnerabilityIndex, + riskLevel, + }; + }) + .sort((a, b) => b.vulnerabilityIndex - a.vulnerabilityIndex); +} + +function findBestPlayer( + playerDepths: PlayerHeroDepth[], + opponentBanCounts: Map, + totalOpponentMaps: number +): BestPlayerHighlight | null { + if (playerDepths.length === 0) return null; + + const best = playerDepths[0]; + if (best.heroes.length === 0) return null; + + const primaryHero = best.heroes[0].hero; + const banCount = opponentBanCounts.get(primaryHero) ?? 0; + const banRate = + totalOpponentMaps > 0 ? (banCount / totalOpponentMaps) * 100 : 0; + + return { + playerName: best.playerName, + role: best.role, + primaryHero, + compositeZScore: best.heroes[0].compositeZScore, + mapsPlayed: best.heroes.reduce((sum, h) => sum + h.mapsPlayed, 0), + isTargetedByBans: banRate >= 15, + banTargetRate: banRate, + }; +} + +export type IntelligenceServiceInterface = { + readonly getPlayerIntelligence: ( + userTeamId: number, + opponentAbbr: string | null, + profile?: DataAvailabilityProfile + ) => Effect.Effect; +}; + +export class IntelligenceService extends Context.Tag( + "@app/data/player/IntelligenceService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make = Effect.gen(function* () { + const sharedData = yield* TeamSharedDataService; + const scrimOpponent = yield* ScrimOpponentService; + const scouting = yield* ScoutingService; + + function getPlayerIntelligence( + userTeamId: number, + opponentAbbr: string | null, + profile?: DataAvailabilityProfile + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + userTeamId, + opponentAbbr, + hasProfile: !!profile, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("userTeamId", userTeamId); + yield* Effect.annotateCurrentSpan("opponentAbbr", String(opponentAbbr)); + const baseData = yield* sharedData.getBaseTeamData(userTeamId).pipe( + Effect.mapError( + (error) => + new IntelligenceQueryError({ + operation: "fetch base team data", + cause: error, + }) + ), + Effect.withSpan("player.intelligence.fetchBaseTeamData", { + attributes: { userTeamId }, + }) + ); + + const { allPlayerStats, teamRoster, teamRosterSet } = baseData; + + const allPlayerHeroes = new Map(); + for (const player of teamRoster) { + const heroes = aggregatePlayerHeroes( + player, + allPlayerStats, + teamRosterSet + ); + if (heroes.length > 0) { + allPlayerHeroes.set(player, heroes); + } + } + + const zScores = computeIntraTeamZScores(allPlayerHeroes); + const playerDepths = buildPlayerDepths(allPlayerHeroes, zScores); + + const heroBans = yield* Effect.tryPromise({ + try: () => + prisma.heroBan.findMany({ + where: { MapDataId: { in: baseData.mapDataIds } }, + select: { MapDataId: true, hero: true }, + }), + catch: (error) => + new IntelligenceQueryError({ + operation: "fetch hero bans", + cause: error, + }), + }).pipe( + Effect.withSpan("player.intelligence.fetchHeroBans", { + attributes: { userTeamId, mapCount: baseData.mapDataIds.length }, + }) + ); + + const substitutionRates = computeSubstitutionRates( + allPlayerHeroes, + allPlayerStats, + teamRosterSet, + heroBans + ); + + const opponentBanCounts = new Map(); + let totalOpponentMaps = 0; + + if (opponentAbbr) { + const opponentMatches = yield* scouting + .getOpponentMatchData(opponentAbbr) + .pipe( + Effect.mapError( + (error) => + new IntelligenceQueryError({ + operation: "fetch opponent match data", + cause: error, + }) + ), + Effect.withSpan("player.intelligence.fetchOpponentMatches", { + attributes: { opponentAbbr }, + }) + ); + + for (const match of opponentMatches) { + const opponentSide = match.team1 === opponentAbbr ? "team2" : "team1"; + for (const map of match.maps) { + totalOpponentMaps++; + for (const ban of map.heroBans) { + if (ban.team === opponentSide) { + opponentBanCounts.set( + ban.hero, + (opponentBanCounts.get(ban.hero) ?? 0) + 1 + ); + } + } + } + } + + const includesScrimData = !!profile && hasScrimData(profile); + if (includesScrimData) { + const scrimBans = yield* scrimOpponent + .getOpponentScrimHeroBans(userTeamId, opponentAbbr) + .pipe( + Effect.mapError( + (error) => + new IntelligenceQueryError({ + operation: "fetch opponent scrim hero bans", + cause: error, + }) + ), + Effect.withSpan("player.intelligence.fetchScrimBans", { + attributes: { userTeamId, opponentAbbr }, + }) + ); + + for (const scrimMap of scrimBans) { + totalOpponentMaps++; + for (const hero of scrimMap.opponentBans) { + opponentBanCounts.set( + hero, + (opponentBanCounts.get(hero) ?? 0) + 1 + ); + } + } + } + } + + wideEvent.total_opponent_maps = totalOpponentMaps; + wideEvent.player_count = allPlayerHeroes.size; + + const vulnerabilities = computeVulnerabilities( + playerDepths, + opponentBanCounts, + totalOpponentMaps + ); + + const bestPlayer = findBestPlayer( + playerDepths, + opponentBanCounts, + totalOpponentMaps + ); + + wideEvent.outcome = "success"; + yield* Metric.increment(playerIntelligenceQuerySuccessTotal); + + return { + playerDepths, + substitutionRates, + vulnerabilities, + bestPlayer, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(playerIntelligenceQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getPlayerIntelligence") + : Effect.logInfo("player.getPlayerIntelligence"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + playerIntelligenceQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getPlayerIntelligence") + ); + } + + const intelligenceCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const parsed = JSON.parse(key) as [ + number, + string | null, + DataAvailabilityProfile | undefined, + ]; + return getPlayerIntelligence(parsed[0], parsed[1], parsed[2]).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ); + }, + }); + + return { + getPlayerIntelligence: ( + userTeamId: number, + opponentAbbr: string | null, + profile?: DataAvailabilityProfile + ) => + intelligenceCache + .get(JSON.stringify([userTeamId, opponentAbbr, profile])) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + } satisfies IntelligenceServiceInterface; +}); + +export const IntelligenceServiceLive = Layer.effect( + IntelligenceService, + make +).pipe( + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(ScrimOpponentServiceLive), + Layer.provide(ScoutingServiceLive), + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/player/metrics.ts b/src/data/player/metrics.ts new file mode 100644 index 000000000..9fe71020f --- /dev/null +++ b/src/data/player/metrics.ts @@ -0,0 +1,187 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const playerMostPlayedQuerySuccessTotal = Metric.counter( + "player.most_played.query.success", + { + description: "Total successful most-played-heroes queries", + incremental: true, + } +); + +export const playerMostPlayedQueryErrorTotal = Metric.counter( + "player.most_played.query.error", + { + description: "Total most-played-heroes query failures", + incremental: true, + } +); + +export const playerMostPlayedQueryDuration = Metric.histogram( + "player.most_played.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of most-played-heroes query duration in milliseconds" +); + +export const playerIntelligenceQuerySuccessTotal = Metric.counter( + "player.intelligence.query.success", + { + description: "Total successful player intelligence queries", + incremental: true, + } +); + +export const playerIntelligenceQueryErrorTotal = Metric.counter( + "player.intelligence.query.error", + { + description: "Total player intelligence query failures", + incremental: true, + } +); + +export const playerIntelligenceQueryDuration = Metric.histogram( + "player.intelligence.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of player intelligence query duration in milliseconds" +); + +export const scoutingPlayersQuerySuccessTotal = Metric.counter( + "player.scouting_players.query.success", + { + description: "Total successful scouting players queries", + incremental: true, + } +); + +export const scoutingPlayersQueryErrorTotal = Metric.counter( + "player.scouting_players.query.error", + { + description: "Total scouting players query failures", + incremental: true, + } +); + +export const scoutingPlayersQueryDuration = Metric.histogram( + "player.scouting_players.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scouting players query duration in milliseconds" +); + +export const playerProfileQuerySuccessTotal = Metric.counter( + "player.profile.query.success", + { + description: "Total successful player profile queries", + incremental: true, + } +); + +export const playerProfileQueryErrorTotal = Metric.counter( + "player.profile.query.error", + { + description: "Total player profile query failures", + incremental: true, + } +); + +export const playerProfileQueryDuration = Metric.histogram( + "player.profile.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of player profile query duration in milliseconds" +); + +export const scoutingAnalyticsQuerySuccessTotal = Metric.counter( + "player.scouting_analytics.query.success", + { + description: "Total successful scouting analytics queries", + incremental: true, + } +); + +export const scoutingAnalyticsQueryErrorTotal = Metric.counter( + "player.scouting_analytics.query.error", + { + description: "Total scouting analytics query failures", + incremental: true, + } +); + +export const scoutingAnalyticsQueryDuration = Metric.histogram( + "player.scouting_analytics.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of scouting analytics query duration in milliseconds" +); + +export const playerTargetsQuerySuccessTotal = Metric.counter( + "player.targets.query.success", + { + description: "Total successful player targets queries", + incremental: true, + } +); + +export const playerTargetsQueryErrorTotal = Metric.counter( + "player.targets.query.error", + { + description: "Total player targets query failures", + incremental: true, + } +); + +export const playerTargetsQueryDuration = Metric.histogram( + "player.targets.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of player targets query duration in milliseconds" +); + +export const teamTargetsQuerySuccessTotal = Metric.counter( + "player.team_targets.query.success", + { + description: "Total successful team targets queries", + incremental: true, + } +); + +export const teamTargetsQueryErrorTotal = Metric.counter( + "player.team_targets.query.error", + { + description: "Total team targets query failures", + incremental: true, + } +); + +export const teamTargetsQueryDuration = Metric.histogram( + "player.team_targets.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team targets query duration in milliseconds" +); + +export const recentScrimStatsQuerySuccessTotal = Metric.counter( + "player.recent_scrim_stats.query.success", + { + description: "Total successful recent scrim stats queries", + incremental: true, + } +); + +export const recentScrimStatsQueryErrorTotal = Metric.counter( + "player.recent_scrim_stats.query.error", + { + description: "Total recent scrim stats query failures", + incremental: true, + } +); + +export const recentScrimStatsQueryDuration = Metric.histogram( + "player.recent_scrim_stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of recent scrim stats query duration in milliseconds" +); + +export const playerCacheRequestTotal = Metric.counter("player.cache.request", { + description: "Total player data cache requests", + incremental: true, +}); + +export const playerCacheMissTotal = Metric.counter("player.cache.miss", { + description: "Total player data cache misses (triggered lookup)", + incremental: true, +}); diff --git a/src/data/player/player-service.ts b/src/data/player/player-service.ts new file mode 100644 index 000000000..13af9ecda --- /dev/null +++ b/src/data/player/player-service.ts @@ -0,0 +1,132 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { PlayerQueryError } from "./errors"; +import { + playerCacheRequestTotal, + playerCacheMissTotal, + playerMostPlayedQueryDuration, + playerMostPlayedQueryErrorTotal, + playerMostPlayedQuerySuccessTotal, +} from "./metrics"; +import type { MostPlayedHeroRow } from "./types"; + +export type PlayerServiceInterface = { + readonly getMostPlayedHeroes: ( + mapId: number + ) => Effect.Effect; +}; + +export class PlayerService extends Context.Tag( + "@app/data/player/PlayerService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getMostPlayedHeroes( + mapId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new PlayerQueryError({ + operation: "resolve map data id", + cause: error, + }), + }).pipe( + Effect.withSpan("player.mostPlayed.resolveMapDataId", { + attributes: { mapId }, + }) + ); + + wideEvent.mapDataId = mapDataId; + + const rows = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: mapDataId }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + }, + orderBy: { hero_time_played: "desc" }, + distinct: ["player_name"], + }), + catch: (error) => + new PlayerQueryError({ + operation: "fetch most played heroes", + cause: error, + }), + }).pipe( + Effect.withSpan("player.mostPlayed.fetchPlayerStats", { + attributes: { mapId, mapDataId }, + }) + ); + + wideEvent.row_count = rows.length; + wideEvent.outcome = "success"; + yield* Metric.increment(playerMostPlayedQuerySuccessTotal); + return rows; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(playerMostPlayedQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getMostPlayedHeroes") + : Effect.logInfo("player.getMostPlayedHeroes"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + playerMostPlayedQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getMostPlayedHeroes") + ); + } + + const mostPlayedCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapId: number) => + getMostPlayedHeroes(mapId).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ), + }); + + return { + getMostPlayedHeroes: (mapId: number) => + mostPlayedCache + .get(mapId) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + } satisfies PlayerServiceInterface; + } +); + +export const PlayerServiceLive = Layer.effect(PlayerService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/player/scouting-analytics-service.ts b/src/data/player/scouting-analytics-service.ts new file mode 100644 index 000000000..e56a3b240 --- /dev/null +++ b/src/data/player/scouting-analytics-service.ts @@ -0,0 +1,1150 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { ValidStatColumn } from "@/lib/stat-percentiles"; +import { removeDuplicateRows } from "@/lib/utils"; +import type { HeroName } from "@/types/heroes"; +import { heroRoleMapping } from "@/types/heroes"; +import { CalculatedStatType, type PlayerStat, Prisma } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScoutingAnalyticsQueryError } from "./errors"; +import { + playerCacheRequestTotal, + playerCacheMissTotal, + scoutingAnalyticsQueryDuration, + scoutingAnalyticsQueryErrorTotal, + scoutingAnalyticsQuerySuccessTotal, +} from "./metrics"; +import type { + AccuracyStats, + AdvancedMetrics, + CompetitiveMapWinrates, + InsightItem, + KillPatterns, + PlayerScoutingAnalytics, + RoleDistributionEntry, + ScoutingHeroPerformance, +} from "./types"; + +type HeroRole = "Tank" | "Damage" | "Support"; + +type HeroStatZScore = { + stat: ValidStatColumn; + per10: number; + zScore: number | null; + heroAvgPer10: number; + heroStdPer10: number; + sampleSize: number; +}; + +const MIN_HERO_TIME_SECONDS = 120; +const MIN_HERO_MAPS = 2; + +const BASELINE_STAT_COLUMNS: ValidStatColumn[] = [ + "eliminations", + "deaths", + "hero_damage_dealt", + "healing_dealt", + "damage_blocked", +]; + +type StatDistributionBaseline = { + hero: string; + stat_name: string; + hero_avg_per10: number; + hero_std_per10: number; + total_players: number; +}; + +type BatchedBaselineRow = { + hero: string; + avg_eliminations_per10: number | null; + std_eliminations_per10: number | null; + avg_deaths_per10: number | null; + std_deaths_per10: number | null; + avg_hero_damage_dealt_per10: number | null; + std_hero_damage_dealt_per10: number | null; + avg_healing_dealt_per10: number | null; + std_healing_dealt_per10: number | null; + avg_damage_blocked_per10: number | null; + std_damage_blocked_per10: number | null; + total_players: number; +}; + +function createBaselineKey(hero: string, stat: ValidStatColumn): string { + return `${hero}::${stat}`; +} + +function calculatePer10(value: number, timePlayed: number): number { + if (timePlayed === 0) return 0; + return (value / timePlayed) * 600; +} + +function calculatePercentage(value: number, total: number): number { + if (total === 0) return 0; + return (value / total) * 100; +} + +function calculateZScoreFromBaseline( + baseline: StatDistributionBaseline, + stat: ValidStatColumn, + per10Value: number +): number | null { + if (!baseline.hero_std_per10 || baseline.hero_std_per10 === 0) return null; + const isInverted = stat === "deaths" || stat === "damage_taken"; + const zScore = isInverted + ? (baseline.hero_avg_per10 - per10Value) / baseline.hero_std_per10 + : (per10Value - baseline.hero_avg_per10) / baseline.hero_std_per10; + if (!Number.isFinite(zScore)) return null; + return zScore; +} + +async function fetchBaselinesByHeroes( + heroes: HeroName[], + minMaps = 5, + minTimeSeconds = 300, + sampleLimit = 150 +): Promise> { + if (heroes.length === 0) return new Map(); + + const rows = await prisma.$queryRaw` + WITH + final_rows AS ( + SELECT DISTINCT ON ("MapDataId", player_name, player_hero) + player_name, + player_hero, + eliminations, + deaths, + hero_damage_dealt, + healing_dealt, + damage_blocked, + hero_time_played + FROM + "PlayerStat" + WHERE + player_hero IN (${Prisma.join(heroes)}) + AND hero_time_played >= 60 + ORDER BY + "MapDataId", + player_name, + player_hero, + round_number DESC, + id DESC + ), + per_player_totals AS ( + SELECT + player_hero, + player_name, + COUNT(*) AS maps, + (SUM(eliminations)::numeric / SUM(hero_time_played)) * 600.0 AS elims_per10, + (SUM(deaths)::numeric / SUM(hero_time_played)) * 600.0 AS deaths_per10, + (SUM(hero_damage_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS dmg_per10, + (SUM(healing_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS heal_per10, + (SUM(damage_blocked)::numeric / SUM(hero_time_played)) * 600.0 AS block_per10 + FROM + final_rows + GROUP BY + player_name, player_hero + HAVING + COUNT(*) >= ${minMaps} + AND SUM(hero_time_played) >= ${minTimeSeconds} + ), + ranked AS ( + SELECT + *, + ROW_NUMBER() OVER ( + PARTITION BY player_hero + ORDER BY maps DESC, player_name + ) AS rn + FROM + per_player_totals + ), + limited AS ( + SELECT * FROM ranked WHERE rn <= ${sampleLimit} + ) + SELECT + player_hero AS hero, + ROUND(AVG(elims_per10)::numeric, 2) AS avg_eliminations_per10, + ROUND(STDDEV_SAMP(elims_per10)::numeric, 2) AS std_eliminations_per10, + ROUND(AVG(deaths_per10)::numeric, 2) AS avg_deaths_per10, + ROUND(STDDEV_SAMP(deaths_per10)::numeric, 2) AS std_deaths_per10, + ROUND(AVG(dmg_per10)::numeric, 2) AS avg_hero_damage_dealt_per10, + ROUND(STDDEV_SAMP(dmg_per10)::numeric, 2) AS std_hero_damage_dealt_per10, + ROUND(AVG(heal_per10)::numeric, 2) AS avg_healing_dealt_per10, + ROUND(STDDEV_SAMP(heal_per10)::numeric, 2) AS std_healing_dealt_per10, + ROUND(AVG(block_per10)::numeric, 2) AS avg_damage_blocked_per10, + ROUND(STDDEV_SAMP(block_per10)::numeric, 2) AS std_damage_blocked_per10, + COUNT(*)::int AS total_players + FROM + limited + GROUP BY + player_hero + `; + + const statColumnMapping: Record< + string, + { avg: keyof BatchedBaselineRow; std: keyof BatchedBaselineRow } + > = { + eliminations: { + avg: "avg_eliminations_per10", + std: "std_eliminations_per10", + }, + deaths: { avg: "avg_deaths_per10", std: "std_deaths_per10" }, + hero_damage_dealt: { + avg: "avg_hero_damage_dealt_per10", + std: "std_hero_damage_dealt_per10", + }, + healing_dealt: { + avg: "avg_healing_dealt_per10", + std: "std_healing_dealt_per10", + }, + damage_blocked: { + avg: "avg_damage_blocked_per10", + std: "std_damage_blocked_per10", + }, + }; + + const result = new Map(); + for (const row of rows) { + for (const stat of BASELINE_STAT_COLUMNS) { + const mapping = statColumnMapping[stat]; + const avg = Number(row[mapping.avg]); + const std = Number(row[mapping.std]); + if (Number.isNaN(avg) || Number.isNaN(std)) continue; + + result.set(createBaselineKey(row.hero, stat), { + hero: row.hero, + stat_name: stat, + hero_avg_per10: avg, + hero_std_per10: std, + total_players: row.total_players, + }); + } + } + + return result; +} + +type HeroAgg = { + hero: string; + role: HeroRole; + mapIds: Set; + totalTime: number; + eliminations: number; + deaths: number; + heroDamageDealt: number; + healingDealt: number; + damageBlocked: number; +}; + +function aggregateStatsByHero(stats: PlayerStat[]): HeroAgg[] { + const heroData = new Map(); + + for (const stat of stats) { + if (!stat.MapDataId) continue; + if (stat.hero_time_played < MIN_HERO_TIME_SECONDS) continue; + + let data = heroData.get(stat.player_hero); + if (!data) { + const role = heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; + data = { + hero: stat.player_hero, + role, + mapIds: new Set(), + totalTime: 0, + eliminations: 0, + deaths: 0, + heroDamageDealt: 0, + healingDealt: 0, + damageBlocked: 0, + }; + heroData.set(stat.player_hero, data); + } + + if (!data.mapIds.has(stat.MapDataId)) { + data.mapIds.add(stat.MapDataId); + data.totalTime += stat.hero_time_played; + data.eliminations += stat.eliminations; + data.deaths += stat.deaths; + data.heroDamageDealt += stat.hero_damage_dealt; + data.healingDealt += stat.healing_dealt; + data.damageBlocked += stat.damage_blocked; + } + } + + return Array.from(heroData.values()) + .filter((h) => h.mapIds.size >= MIN_HERO_MAPS) + .sort((a, b) => b.totalTime - a.totalTime); +} + +function computeHeroZScores( + heroAggs: HeroAgg[], + baselines: Map +): ScoutingHeroPerformance[] { + return heroAggs.map((h) => { + const stats: HeroStatZScore[] = []; + const zScoresForComposite: { stat: ValidStatColumn; z: number }[] = []; + + for (const stat of BASELINE_STAT_COLUMNS) { + const per10 = calculatePer10( + stat === "eliminations" + ? h.eliminations + : stat === "deaths" + ? h.deaths + : stat === "hero_damage_dealt" + ? h.heroDamageDealt + : stat === "healing_dealt" + ? h.healingDealt + : h.damageBlocked, + h.totalTime + ); + + const baseline = baselines.get(createBaselineKey(h.hero, stat)); + const zScore = baseline + ? calculateZScoreFromBaseline(baseline, stat, per10) + : null; + + stats.push({ + stat, + per10, + zScore, + heroAvgPer10: baseline?.hero_avg_per10 ?? 0, + heroStdPer10: baseline?.hero_std_per10 ?? 0, + sampleSize: baseline?.total_players ?? 0, + }); + + if (zScore !== null) { + zScoresForComposite.push({ stat, z: zScore }); + } + } + + const weights = + h.role === "Tank" + ? { + eliminations: 0.2, + deaths: 0.3, + hero_damage_dealt: 0.2, + healing_dealt: 0, + damage_blocked: 0.3, + } + : h.role === "Support" + ? { + eliminations: 0.1, + deaths: 0.25, + hero_damage_dealt: 0.14, + healing_dealt: 0.35, + damage_blocked: 0, + } + : { + eliminations: 0.3, + deaths: 0.2, + hero_damage_dealt: 0.3, + healing_dealt: 0, + damage_blocked: 0, + }; + + let compositeZScore = 0; + for (const { stat, z } of zScoresForComposite) { + const weight = weights[stat as keyof typeof weights] ?? 0; + compositeZScore += z * weight; + } + + return { + hero: h.hero, + role: h.role, + mapsPlayed: h.mapIds.size, + totalTimePlayed: h.totalTime, + stats, + compositeZScore, + }; + }); +} + +function buildAdvancedMetrics( + calculatedStats: { stat: string; value: number }[], + perMapStatGroups: PlayerStat[][] +): AdvancedMetrics { + const metrics: AdvancedMetrics = { + mvpScore: 0, + fletaDeadliftPercentage: 0, + firstPickPercentage: 0, + firstPickCount: 0, + firstDeathPercentage: 0, + firstDeathCount: 0, + fightReversalPercentage: 0, + killsPerUltimate: 0, + averageUltChargeTime: 0, + averageDroughtTime: 0, + duelWinratePercentage: 0, + consistencyScore: 0, + }; + + const counts: Record = {}; + + for (const cs of calculatedStats) { + switch (cs.stat) { + case CalculatedStatType.MVP_SCORE: + metrics.mvpScore += cs.value; + counts.mvp = (counts.mvp ?? 0) + 1; + break; + case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: + metrics.fletaDeadliftPercentage += cs.value; + counts.fleta = (counts.fleta ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_PERCENTAGE: + metrics.firstPickPercentage += cs.value; + counts.firstPick = (counts.firstPick ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_COUNT: + metrics.firstPickCount += cs.value; + break; + case CalculatedStatType.FIRST_DEATH_PERCENTAGE: + metrics.firstDeathPercentage += cs.value; + counts.firstDeath = (counts.firstDeath ?? 0) + 1; + break; + case CalculatedStatType.FIRST_DEATH_COUNT: + metrics.firstDeathCount += cs.value; + break; + case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: + metrics.fightReversalPercentage += cs.value; + counts.fightReversal = (counts.fightReversal ?? 0) + 1; + break; + case CalculatedStatType.KILLS_PER_ULTIMATE: + metrics.killsPerUltimate += cs.value; + counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: + metrics.averageUltChargeTime += cs.value; + counts.ultCharge = (counts.ultCharge ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_DROUGHT_TIME: + metrics.averageDroughtTime += cs.value; + counts.drought = (counts.drought ?? 0) + 1; + break; + case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: + metrics.duelWinratePercentage += cs.value; + counts.duel = (counts.duel ?? 0) + 1; + break; + } + } + + if (counts.mvp) metrics.mvpScore /= counts.mvp; + if (counts.fleta) metrics.fletaDeadliftPercentage /= counts.fleta; + if (counts.firstPick) metrics.firstPickPercentage /= counts.firstPick; + if (counts.firstDeath) metrics.firstDeathPercentage /= counts.firstDeath; + if (counts.fightReversal) + metrics.fightReversalPercentage /= counts.fightReversal; + if (counts.killsPerUlt) metrics.killsPerUltimate /= counts.killsPerUlt; + if (counts.ultCharge) metrics.averageUltChargeTime /= counts.ultCharge; + if (counts.drought) metrics.averageDroughtTime /= counts.drought; + if (counts.duel) metrics.duelWinratePercentage /= counts.duel; + + metrics.consistencyScore = computeConsistencyScore(perMapStatGroups); + + return metrics; +} + +function computeConsistencyScore(perMapStatGroups: PlayerStat[][]): number { + if (perMapStatGroups.length < 3) return 0; + + const per10Values: Record = { + elims: [], + deaths: [], + damage: [], + healing: [], + }; + + for (const mapStats of perMapStatGroups) { + let totalTime = 0; + let totalElims = 0; + let totalDeaths = 0; + let totalDamage = 0; + let totalHealing = 0; + + for (const s of mapStats) { + totalTime += s.hero_time_played; + totalElims += s.eliminations; + totalDeaths += s.deaths; + totalDamage += s.hero_damage_dealt; + totalHealing += s.healing_dealt; + } + + if (totalTime > 0) { + per10Values.elims.push(calculatePer10(totalElims, totalTime)); + per10Values.deaths.push(calculatePer10(totalDeaths, totalTime)); + per10Values.damage.push(calculatePer10(totalDamage, totalTime)); + per10Values.healing.push(calculatePer10(totalHealing, totalTime)); + } + } + + function mean(arr: number[]) { + return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; + } + + function stddev(arr: number[]) { + if (arr.length < 2) return 0; + const m = mean(arr); + return Math.sqrt( + arr.reduce((sum, v) => sum + (v - m) ** 2, 0) / (arr.length - 1) + ); + } + + const cvs = [ + per10Values.elims, + per10Values.deaths, + per10Values.damage, + per10Values.healing, + ] + .map((vals) => { + const m = mean(vals); + return m > 0 ? stddev(vals) / m : 0; + }) + .filter((cv) => cv > 0); + + if (cvs.length === 0) return 0; + const averageCV = mean(cvs); + return Math.max(0, Math.min(100, (1 - averageCV) * 100)); +} + +function buildKillPatterns( + kills: { + attacker_hero: string; + victim_hero: string; + event_ability: string; + }[], + deaths: { attacker_hero: string; victim_hero: string }[] +): KillPatterns { + const eliminatedCounts = new Map(); + for (const kill of kills) { + eliminatedCounts.set( + kill.victim_hero, + (eliminatedCounts.get(kill.victim_hero) ?? 0) + 1 + ); + } + + const diedToCounts = new Map(); + for (const death of deaths) { + diedToCounts.set( + death.attacker_hero, + (diedToCounts.get(death.attacker_hero) ?? 0) + 1 + ); + } + + const methodCounts = new Map(); + for (const kill of kills) { + const method = kill.event_ability || "Primary Fire"; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + } + + return { + topHeroesEliminated: Array.from(eliminatedCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([hero, count]) => ({ hero, count })), + topHeroesDiedTo: Array.from(diedToCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([hero, count]) => ({ hero, count })), + killMethods: Array.from(methodCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([method, count]) => ({ method, count })), + }; +} + +function buildRoleDistribution(stats: PlayerStat[]): RoleDistributionEntry[] { + const roleTime = new Map(); + let totalTime = 0; + + for (const stat of stats) { + const role = heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; + roleTime.set(role, (roleTime.get(role) ?? 0) + stat.hero_time_played); + totalTime += stat.hero_time_played; + } + + return Array.from(roleTime.entries()) + .map(([role, timePlayed]) => ({ + role, + timePlayed, + percentage: calculatePercentage(timePlayed, totalTime), + })) + .sort((a, b) => b.timePlayed - a.timePlayed); +} + +async function fetchCompetitiveMapWinrates( + playerName: string +): Promise { + const assignments = await prisma.scoutingHeroAssignment.findMany({ + where: { playerName: { equals: playerName, mode: "insensitive" } }, + select: { + team: true, + mapResult: { + select: { + id: true, + mapName: true, + mapType: true, + winner: true, + match: { + select: { + team1: true, + team2: true, + }, + }, + }, + }, + }, + }); + + const mapResults = new Map< + number, + { mapName: string; mapType: string; won: boolean } + >(); + + for (const assignment of assignments) { + if (mapResults.has(assignment.mapResult.id)) continue; + + const isTeam1 = assignment.team === "team1"; + const playerTeamAbbr = isTeam1 + ? assignment.mapResult.match.team1 + : assignment.mapResult.match.team2; + + mapResults.set(assignment.mapResult.id, { + mapName: assignment.mapResult.mapName, + mapType: assignment.mapResult.mapType, + won: assignment.mapResult.winner === playerTeamAbbr, + }); + } + + const byMapType = new Map(); + const byMap = new Map< + string, + { mapType: string; wins: number; losses: number } + >(); + + for (const [, result] of mapResults) { + const typeEntry = byMapType.get(result.mapType) ?? { + wins: 0, + losses: 0, + }; + if (result.won) typeEntry.wins++; + else typeEntry.losses++; + byMapType.set(result.mapType, typeEntry); + + const mapEntry = byMap.get(result.mapName) ?? { + mapType: result.mapType, + wins: 0, + losses: 0, + }; + if (result.won) mapEntry.wins++; + else mapEntry.losses++; + byMap.set(result.mapName, mapEntry); + } + + return { + byMapType: Array.from(byMapType.entries()) + .map(([mapType, data]) => ({ + mapType, + wins: data.wins, + losses: data.losses, + played: data.wins + data.losses, + winRate: + data.wins + data.losses > 0 + ? (data.wins / (data.wins + data.losses)) * 100 + : 0, + })) + .sort((a, b) => b.played - a.played), + byMap: Array.from(byMap.entries()) + .map(([mapName, data]) => ({ + mapName, + mapType: data.mapType, + wins: data.wins, + losses: data.losses, + played: data.wins + data.losses, + winRate: + data.wins + data.losses > 0 + ? (data.wins / (data.wins + data.losses)) * 100 + : 0, + })) + .sort((a, b) => b.played - a.played), + }; +} + +function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { + strengths: InsightItem[]; + weaknesses: InsightItem[]; +} { + const strengths: InsightItem[] = []; + const weaknesses: InsightItem[] = []; + + if (analytics.scrimData) { + const { heroes, advancedMetrics } = analytics.scrimData; + + for (const hero of heroes) { + if (hero.compositeZScore > 1.0) { + strengths.push({ + category: "hero", + label: `Elite ${hero.hero}`, + detail: `Composite z-score of +${hero.compositeZScore.toFixed(1)}\u03C3 across ${hero.mapsPlayed} maps`, + value: hero.compositeZScore, + }); + } else if (hero.compositeZScore < -0.5 && hero.mapsPlayed >= 3) { + weaknesses.push({ + category: "hero", + label: `Below average ${hero.hero}`, + detail: `Composite z-score of ${hero.compositeZScore.toFixed(1)}\u03C3 across ${hero.mapsPlayed} maps`, + value: hero.compositeZScore, + }); + } + } + + for (const hero of heroes) { + for (const stat of hero.stats) { + if ( + stat.zScore !== null && + stat.zScore > 1.5 && + stat.sampleSize >= 10 + ) { + const statLabel = stat.stat.replace(/_/g, " "); + strengths.push({ + category: "stat", + label: `Exceptional ${statLabel}`, + detail: `+${stat.zScore.toFixed(1)}\u03C3 on ${hero.hero} (${stat.per10.toFixed(0)}/10 vs avg ${stat.heroAvgPer10.toFixed(0)}/10)`, + value: stat.zScore, + }); + } else if ( + stat.zScore !== null && + stat.zScore < -1.5 && + stat.sampleSize >= 10 + ) { + const statLabel = stat.stat.replace(/_/g, " "); + weaknesses.push({ + category: "stat", + label: `Weak ${statLabel}`, + detail: `${stat.zScore.toFixed(1)}\u03C3 on ${hero.hero} (${stat.per10.toFixed(0)}/10 vs avg ${stat.heroAvgPer10.toFixed(0)}/10)`, + value: stat.zScore, + }); + } + } + } + + if (advancedMetrics.firstPickPercentage > 25) { + strengths.push({ + category: "combat", + label: "High-impact opener", + detail: `${advancedMetrics.firstPickPercentage.toFixed(0)}% first pick rate with ${advancedMetrics.firstPickCount} total first picks`, + value: advancedMetrics.firstPickPercentage, + }); + } + + if (advancedMetrics.firstDeathPercentage > 30) { + weaknesses.push({ + category: "combat", + label: "Frequently caught out", + detail: `${advancedMetrics.firstDeathPercentage.toFixed(0)}% first death rate (${advancedMetrics.firstDeathCount} first deaths)`, + value: advancedMetrics.firstDeathPercentage, + }); + } + + if (advancedMetrics.consistencyScore > 80) { + strengths.push({ + category: "stat", + label: "Highly consistent", + detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating across maps`, + value: advancedMetrics.consistencyScore, + }); + } else if ( + advancedMetrics.consistencyScore > 0 && + advancedMetrics.consistencyScore < 50 + ) { + weaknesses.push({ + category: "stat", + label: "Inconsistent performer", + detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating \u2014 high variance across maps`, + value: -advancedMetrics.consistencyScore, + }); + } + + if (heroes.length >= 2) { + const delta = heroes[0].compositeZScore - heroes[1].compositeZScore; + if (delta > 1.5) { + weaknesses.push({ + category: "hero", + label: "Narrow hero pool", + detail: `${delta.toFixed(1)}\u03C3 drop-off from ${heroes[0].hero} to ${heroes[1].hero}`, + value: -delta, + }); + } + } + + if (advancedMetrics.fightReversalPercentage > 15) { + strengths.push({ + category: "combat", + label: "Clutch player", + detail: `${advancedMetrics.fightReversalPercentage.toFixed(0)}% fight reversal rate`, + value: advancedMetrics.fightReversalPercentage, + }); + } + } + + for (const entry of analytics.competitiveMapWinrates.byMapType) { + if (entry.winRate > 65 && entry.played >= 5) { + strengths.push({ + category: "map", + label: `Dominant on ${entry.mapType}`, + detail: `${entry.winRate.toFixed(0)}% win rate across ${entry.played} maps`, + value: entry.winRate, + }); + } else if (entry.winRate < 35 && entry.played >= 3) { + weaknesses.push({ + category: "map", + label: `Struggles on ${entry.mapType}`, + detail: `${entry.winRate.toFixed(0)}% win rate across ${entry.played} maps`, + value: -entry.winRate, + }); + } + } + + strengths.sort((a, b) => b.value - a.value); + weaknesses.sort((a, b) => a.value - b.value); + + return { + strengths: strengths.slice(0, 4), + weaknesses: weaknesses.slice(0, 4), + }; +} + +export type ScoutingAnalyticsServiceInterface = { + readonly getPlayerScoutingAnalytics: ( + playerName: string + ) => Effect.Effect; +}; + +export class ScoutingAnalyticsService extends Context.Tag( + "@app/data/player/ScoutingAnalyticsService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = + Effect.gen(function* () { + function getPlayerScoutingAnalytics( + playerName: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { playerName }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("playerName", playerName); + const competitiveMapWinrates = yield* Effect.tryPromise({ + try: () => fetchCompetitiveMapWinrates(playerName), + catch: (error) => + new ScoutingAnalyticsQueryError({ + operation: "fetch competitive map winrates", + cause: error, + }), + }).pipe( + Effect.withSpan( + "player.scoutingAnalytics.fetchCompetitiveMapWinrates", + { attributes: { playerName } } + ) + ); + + const playerStatCheck = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findFirst({ + where: { + player_name: { equals: playerName, mode: "insensitive" }, + }, + select: { id: true }, + }), + catch: (error) => + new ScoutingAnalyticsQueryError({ + operation: "check player stat existence", + cause: error, + }), + }).pipe( + Effect.withSpan("player.scoutingAnalytics.checkPlayerExists", { + attributes: { playerName }, + }) + ); + + if (!playerStatCheck) { + wideEvent.has_scrim_data = false; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingAnalyticsQuerySuccessTotal); + return { + scrimData: null, + competitiveMapWinrates, + scrimMapWinrates: null, + strengths: [], + weaknesses: [], + }; + } + + const allStats = yield* Effect.tryPromise({ + try: async () => { + const raw = await prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "player_name" ILIKE ${playerName} + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."player_name" ILIKE ${playerName}`; + return removeDuplicateRows(raw); + }, + catch: (error) => + new ScoutingAnalyticsQueryError({ + operation: "fetch all player stats", + cause: error, + }), + }).pipe( + Effect.withSpan("player.scoutingAnalytics.fetchAllStats", { + attributes: { playerName }, + }) + ); + + if (allStats.length === 0) { + wideEvent.has_scrim_data = false; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingAnalyticsQuerySuccessTotal); + return { + scrimData: null, + competitiveMapWinrates, + scrimMapWinrates: null, + strengths: [], + weaknesses: [], + }; + } + + const mapDataIds = [ + ...new Set( + allStats + .map((s) => s.MapDataId) + .filter((id): id is number => id !== null) + ), + ]; + + const [kills, deaths, calculatedStats] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIds }, + attacker_name: { + equals: playerName, + mode: "insensitive", + }, + }, + select: { + attacker_hero: true, + victim_hero: true, + event_ability: true, + }, + }), + prisma.kill.findMany({ + where: { + MapDataId: { in: mapDataIds }, + victim_name: { + equals: playerName, + mode: "insensitive", + }, + }, + select: { attacker_hero: true, victim_hero: true }, + }), + prisma.calculatedStat.findMany({ + where: { + MapDataId: { in: mapDataIds }, + playerName: { + equals: playerName, + mode: "insensitive", + }, + }, + select: { stat: true, value: true, MapDataId: true }, + }), + ]), + catch: (error) => + new ScoutingAnalyticsQueryError({ + operation: "fetch kills, deaths, and calculated stats", + cause: error, + }), + }).pipe( + Effect.withSpan("player.scoutingAnalytics.fetchCombatData", { + attributes: { playerName, mapCount: mapDataIds.length }, + }) + ); + + const heroAggs = aggregateStatsByHero(allStats); + const heroNames = heroAggs + .map((h) => h.hero) + .filter((h): h is HeroName => h in heroRoleMapping); + + const baselines = yield* Effect.tryPromise({ + try: () => fetchBaselinesByHeroes(heroNames), + catch: (error) => + new ScoutingAnalyticsQueryError({ + operation: "fetch baselines by heroes", + cause: error, + }), + }).pipe( + Effect.withSpan("player.scoutingAnalytics.fetchBaselines", { + attributes: { playerName, heroCount: heroNames.length }, + }) + ); + + const heroes = computeHeroZScores(heroAggs, baselines); + + const perMapStatGroups: PlayerStat[][] = []; + const statsByMap = new Map(); + for (const stat of allStats) { + if (!stat.MapDataId) continue; + const existing = statsByMap.get(stat.MapDataId) ?? []; + existing.push(stat); + statsByMap.set(stat.MapDataId, existing); + } + for (const [, group] of statsByMap) { + perMapStatGroups.push(group); + } + + const advancedMetrics = buildAdvancedMetrics( + calculatedStats, + perMapStatGroups + ); + + const killPatterns = buildKillPatterns(kills, deaths); + const roleDistribution = buildRoleDistribution(allStats); + + let totalTime = 0; + let totalElims = 0; + let totalDeaths = 0; + let totalHeroDamage = 0; + let totalHealing = 0; + let totalShotsFired = 0; + let totalShotsHit = 0; + let totalCriticalHits = 0; + let totalScopedShots = 0; + let totalScopedShotsHit = 0; + + for (const stat of allStats) { + totalTime += stat.hero_time_played; + totalElims += stat.eliminations; + totalDeaths += stat.deaths; + totalHeroDamage += stat.hero_damage_dealt; + totalHealing += stat.healing_dealt; + totalShotsFired += stat.shots_fired; + totalShotsHit += stat.shots_hit; + totalCriticalHits += stat.critical_hits; + totalScopedShots += stat.scoped_shots; + totalScopedShotsHit += stat.scoped_shots_hit; + } + + const accuracy: AccuracyStats = { + weaponAccuracy: calculatePercentage(totalShotsHit, totalShotsFired), + criticalHitAccuracy: calculatePercentage( + totalCriticalHits, + totalShotsHit + ), + scopedAccuracy: calculatePercentage( + totalScopedShotsHit, + totalScopedShots + ), + }; + + const scrimData = { + available: true as const, + mapsPlayed: mapDataIds.length, + totalTimePlayed: totalTime, + heroes, + advancedMetrics, + killPatterns, + roleDistribution, + accuracy, + kdRatio: totalDeaths > 0 ? totalElims / totalDeaths : totalElims, + eliminationsPer10: calculatePer10(totalElims, totalTime), + deathsPer10: calculatePer10(totalDeaths, totalTime), + heroDamagePer10: calculatePer10(totalHeroDamage, totalTime), + healingDealtPer10: calculatePer10(totalHealing, totalTime), + }; + + const analytics: PlayerScoutingAnalytics = { + scrimData, + competitiveMapWinrates, + scrimMapWinrates: null, + strengths: [], + weaknesses: [], + }; + + const insights = generatePlayerInsights(analytics); + analytics.strengths = insights.strengths; + analytics.weaknesses = insights.weaknesses; + + wideEvent.has_scrim_data = true; + wideEvent.maps_played = mapDataIds.length; + wideEvent.hero_count = heroes.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingAnalyticsQuerySuccessTotal); + + return analytics; + }).pipe( + Effect.tapError((error: ScoutingAnalyticsQueryError) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scoutingAnalyticsQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getPlayerScoutingAnalytics") + : Effect.logInfo("player.getPlayerScoutingAnalytics"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scoutingAnalyticsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getPlayerScoutingAnalytics") + ); + } + + const scoutingAnalyticsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (playerName: string) => + getPlayerScoutingAnalytics(playerName).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ), + }); + + return { + getPlayerScoutingAnalytics: (playerName: string) => + scoutingAnalyticsCache + .get(playerName) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + } satisfies ScoutingAnalyticsServiceInterface; + }); + +export const ScoutingAnalyticsServiceLive = Layer.effect( + ScoutingAnalyticsService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/player/scouting-service.ts b/src/data/player/scouting-service.ts new file mode 100644 index 000000000..4ba622b0c --- /dev/null +++ b/src/data/player/scouting-service.ts @@ -0,0 +1,525 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { ScoutingQueryError } from "./errors"; +import { + playerCacheRequestTotal, + playerCacheMissTotal, + playerProfileQueryDuration, + playerProfileQueryErrorTotal, + playerProfileQuerySuccessTotal, + scoutingPlayersQueryDuration, + scoutingPlayersQueryErrorTotal, + scoutingPlayersQuerySuccessTotal, +} from "./metrics"; +import type { + HeroFrequency, + PlayerProfile, + ScoutingPlayerSummary, + TournamentMatchEntry, + TournamentRecord, +} from "./types"; + +function extractSlug(playerUrl: string): string { + const parts = playerUrl.split("/"); + return parts[parts.length - 1] ?? ""; +} + +export type ScoutingServiceInterface = { + readonly getScoutingPlayers: () => Effect.Effect< + ScoutingPlayerSummary[], + ScoutingQueryError + >; + + readonly getPlayerProfile: ( + slug: string + ) => Effect.Effect; +}; + +export class ScoutingService extends Context.Tag( + "@app/data/player/ScoutingService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getScoutingPlayers(): Effect.Effect< + ScoutingPlayerSummary[], + ScoutingQueryError + > { + const startTime = Date.now(); + const wideEvent: Record = {}; + + return Effect.gen(function* () { + const players = yield* Effect.tryPromise({ + try: () => + prisma.scoutingPlayer.findMany({ + select: { + id: true, + name: true, + team: true, + role: true, + region: true, + status: true, + playerUrl: true, + }, + orderBy: { name: "asc" }, + }), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch scouting players", + cause: error, + }), + }).pipe(Effect.withSpan("player.scouting.fetchPlayers")); + + const result = players.map((p) => ({ + id: p.id, + name: p.name, + team: p.team, + role: p.role, + region: p.region, + status: p.status, + slug: extractSlug(p.playerUrl), + })); + + wideEvent.player_count = result.length; + wideEvent.outcome = "success"; + yield* Metric.increment(scoutingPlayersQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(scoutingPlayersQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getScoutingPlayers") + : Effect.logInfo("player.getScoutingPlayers"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + scoutingPlayersQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getScoutingPlayers") + ); + } + + function getPlayerProfile( + slug: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { slug }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("slug", slug); + const playerUrl = `https://liquipedia.net/overwatch/${slug}`; + + const player = yield* Effect.tryPromise({ + try: () => + prisma.scoutingPlayer.findUnique({ + where: { playerUrl }, + }), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch scouting player by URL", + cause: error, + }), + }).pipe( + Effect.withSpan("player.profile.fetchPlayer", { + attributes: { slug }, + }) + ); + + if (!player) { + wideEvent.outcome = "success"; + wideEvent.found = false; + yield* Metric.increment(playerProfileQuerySuccessTotal); + return null; + } + + const [heroAssignments, rosterEntries] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.scoutingHeroAssignment.findMany({ + where: { + playerName: { equals: player.name, mode: "insensitive" }, + }, + select: { + heroName: true, + mapResultId: true, + team: true, + mapResult: { + select: { + mapName: true, + match: { + select: { + matchDate: true, + team1: true, + team1FullName: true, + team2: true, + team2FullName: true, + team1Score: true, + team2Score: true, + winner: true, + tournament: { select: { title: true } }, + }, + }, + }, + }, + }, + }), + prisma.scoutingRosterPlayer.findMany({ + where: { + displayName: { equals: player.name, mode: "insensitive" }, + }, + select: { + role: true, + didNotPlay: true, + roster: { + select: { + teamName: true, + tournament: { + select: { + id: true, + title: true, + }, + }, + }, + }, + }, + }), + ]), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch hero assignments and roster entries", + cause: error, + }), + }).pipe( + Effect.withSpan("player.profile.fetchAssignmentsAndRoster", { + attributes: { slug, playerName: player.name }, + }) + ); + + const heroCountMap = new Map>(); + for (const assignment of heroAssignments) { + const existing = heroCountMap.get(assignment.heroName) ?? new Set(); + existing.add(assignment.mapResultId); + heroCountMap.set(assignment.heroName, existing); + } + + const heroFrequencies: HeroFrequency[] = Array.from( + heroCountMap.entries() + ) + .map(([hero, mapIds]) => ({ hero, mapCount: mapIds.size })) + .sort((a, b) => b.mapCount - a.mapCount); + + const competitiveMapCount = new Set( + heroAssignments.map((a) => a.mapResultId) + ).size; + + const tournamentMap = new Map< + string, + { + tournamentTitle: string; + teamName: string; + role: string; + matchMap: Map< + string, + { + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + result: "win" | "loss"; + heroesPlayed: Set; + } + >; + } + >(); + + const rosterTournamentIds = new Set(); + const rosterTeamsByTournament = new Map< + number, + { teamName: string; tournamentTitle: string; role: string } + >(); + + for (const entry of rosterEntries) { + const tournamentId = entry.roster.tournament.id; + const tournamentTitle = entry.roster.tournament.title; + const teamName = entry.roster.teamName; + + rosterTournamentIds.add(tournamentId); + rosterTeamsByTournament.set(tournamentId, { + teamName, + tournamentTitle, + role: entry.role, + }); + + if (!tournamentMap.has(tournamentTitle)) { + tournamentMap.set(tournamentTitle, { + tournamentTitle, + teamName, + role: entry.role, + matchMap: new Map(), + }); + } + } + + if (rosterTournamentIds.size > 0) { + const rosterMatches = yield* Effect.tryPromise({ + try: () => + prisma.scoutingMatch.findMany({ + where: { + tournamentId: { + in: Array.from(rosterTournamentIds), + }, + }, + select: { + matchDate: true, + team1: true, + team1FullName: true, + team2: true, + team2FullName: true, + team1Score: true, + team2Score: true, + winner: true, + tournamentId: true, + tournament: { select: { title: true } }, + }, + orderBy: { matchDate: "desc" }, + }), + catch: (error) => + new ScoutingQueryError({ + operation: "fetch roster tournament matches", + cause: error, + }), + }).pipe( + Effect.withSpan("player.profile.fetchRosterMatches", { + attributes: { + slug, + tournamentCount: rosterTournamentIds.size, + }, + }) + ); + + for (const match of rosterMatches) { + const rosterInfo = rosterTeamsByTournament.get(match.tournamentId); + if (!rosterInfo) continue; + + const isTeam1 = + match.team1 === rosterInfo.teamName || + match.team1FullName === rosterInfo.teamName; + const isTeam2 = + match.team2 === rosterInfo.teamName || + match.team2FullName === rosterInfo.teamName; + if (!isTeam1 && !isTeam2) continue; + + const onTeam1Side = isTeam1; + const teamAbbr = onTeam1Side ? match.team1 : match.team2; + + const tournament = tournamentMap.get(rosterInfo.tournamentTitle); + if (!tournament) continue; + + const matchKey = `${match.team1}-${match.team2}-${match.matchDate.toISOString()}`; + if (!tournament.matchMap.has(matchKey)) { + const won = match.winner === teamAbbr; + tournament.matchMap.set(matchKey, { + date: match.matchDate, + opponent: onTeam1Side ? match.team2 : match.team1, + opponentFullName: onTeam1Side + ? match.team2FullName + : match.team1FullName, + teamScore: onTeam1Side ? match.team1Score : match.team2Score, + opponentScore: onTeam1Side + ? match.team2Score + : match.team1Score, + result: won ? "win" : "loss", + heroesPlayed: new Set(), + }); + } + } + } + + for (const assignment of heroAssignments) { + const match = assignment.mapResult.match; + const tournamentTitle = match.tournament.title; + const isTeam1 = assignment.team === "team1"; + const teamAbbr = isTeam1 ? match.team1 : match.team2; + + if (!tournamentMap.has(tournamentTitle)) { + const rosterEntry = rosterEntries.find( + (r) => r.roster.tournament.title === tournamentTitle + ); + tournamentMap.set(tournamentTitle, { + tournamentTitle, + teamName: teamAbbr, + role: rosterEntry?.role ?? player.role, + matchMap: new Map(), + }); + } + + const tournament = tournamentMap.get(tournamentTitle)!; + const matchKey = `${match.team1}-${match.team2}-${match.matchDate.toISOString()}`; + + if (!tournament.matchMap.has(matchKey)) { + const won = match.winner === teamAbbr; + tournament.matchMap.set(matchKey, { + date: match.matchDate, + opponent: isTeam1 ? match.team2 : match.team1, + opponentFullName: isTeam1 + ? match.team2FullName + : match.team1FullName, + teamScore: isTeam1 ? match.team1Score : match.team2Score, + opponentScore: isTeam1 ? match.team2Score : match.team1Score, + result: won ? "win" : "loss", + heroesPlayed: new Set(), + }); + } + + tournament.matchMap + .get(matchKey)! + .heroesPlayed.add(assignment.heroName); + } + + const tournamentRecords: TournamentRecord[] = Array.from( + tournamentMap.values() + ) + .map((t) => { + const matches: TournamentMatchEntry[] = Array.from( + t.matchMap.values() + ) + .map((m) => ({ + date: m.date, + opponent: m.opponent, + opponentFullName: m.opponentFullName, + teamScore: m.teamScore, + opponentScore: m.opponentScore, + result: m.result, + heroesPlayed: Array.from(m.heroesPlayed), + })) + .sort((a, b) => b.date.getTime() - a.date.getTime()); + + const wins = matches.filter((m) => m.result === "win").length; + const losses = matches.length - wins; + + return { + tournamentTitle: t.tournamentTitle, + teamName: t.teamName, + role: t.role, + wins, + losses, + winRate: matches.length > 0 ? (wins / matches.length) * 100 : 0, + matches, + }; + }) + .sort((a, b) => { + const aDate = a.matches[0]?.date.getTime() ?? 0; + const bDate = b.matches[0]?.date.getTime() ?? 0; + return bDate - aDate; + }); + + wideEvent.found = true; + wideEvent.hero_frequency_count = heroFrequencies.length; + wideEvent.competitive_map_count = competitiveMapCount; + wideEvent.tournament_count = tournamentRecords.length; + wideEvent.outcome = "success"; + yield* Metric.increment(playerProfileQuerySuccessTotal); + + return { + id: player.id, + name: player.name, + team: player.team, + status: player.status, + role: player.role, + country: player.country, + signatureHeroes: player.signatureHeroes, + winnings: player.winnings, + region: player.region, + playerUrl: player.playerUrl, + slug: extractSlug(player.playerUrl), + heroFrequencies, + competitiveMapCount, + tournamentRecords, + totalTournaments: tournamentRecords.length, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(playerProfileQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getPlayerProfile") + : Effect.logInfo("player.getPlayerProfile"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + playerProfileQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getPlayerProfile") + ); + } + + const scoutingPlayersCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (_: void) => + getScoutingPlayers().pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ), + }); + + const playerProfileCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (slug: string) => + getPlayerProfile(slug).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ), + }); + + return { + getScoutingPlayers: () => + scoutingPlayersCache + .get(undefined as void) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + getPlayerProfile: (slug: string) => + playerProfileCache + .get(slug) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + } satisfies ScoutingServiceInterface; + } +); + +export const ScoutingServiceLive = Layer.effect(ScoutingService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/player/targets-service.ts b/src/data/player/targets-service.ts new file mode 100644 index 000000000..4e5b9e16b --- /dev/null +++ b/src/data/player/targets-service.ts @@ -0,0 +1,498 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { TargetStatKey } from "@/lib/target-stats"; +import { removeDuplicateRows, toMins } from "@/lib/utils"; +import type { PlayerStat, PlayerTarget } from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { TargetsQueryError } from "./errors"; +import { + playerCacheRequestTotal, + playerCacheMissTotal, + playerTargetsQueryDuration, + playerTargetsQueryErrorTotal, + playerTargetsQuerySuccessTotal, + recentScrimStatsQueryDuration, + recentScrimStatsQueryErrorTotal, + recentScrimStatsQuerySuccessTotal, + teamTargetsQueryDuration, + teamTargetsQueryErrorTotal, + teamTargetsQuerySuccessTotal, +} from "./metrics"; +import type { ScrimStatPoint, TargetProgress } from "./types"; + +export function calculateTargetProgress( + target: PlayerTarget, + recentStats: ScrimStatPoint[] +): Omit { + if (recentStats.length === 0) { + return { + currentValue: target.baselineValue, + progressPercent: 0, + trending: "neutral", + }; + } + + const values = recentStats + .map((s) => s.stats[target.stat]) + .filter((v) => v !== undefined && isFinite(v)); + + if (values.length === 0) { + return { + currentValue: target.baselineValue, + progressPercent: 0, + trending: "neutral", + }; + } + + const currentValue = values.reduce((a, b) => a + b, 0) / values.length; + + const multiplier = + target.targetDirection === "increase" + ? 1 + target.targetPercent / 100 + : 1 - target.targetPercent / 100; + const targetValue = target.baselineValue * multiplier; + + const totalDistance = targetValue - target.baselineValue; + if (Math.abs(totalDistance) < 0.001) { + return { currentValue, progressPercent: 100, trending: "neutral" }; + } + + const currentDistance = currentValue - target.baselineValue; + const progressPercent = Math.min( + 100, + Math.max(0, (currentDistance / totalDistance) * 100) + ); + + let trending: "toward" | "away" | "neutral" = "neutral"; + if (values.length >= 2) { + const recentHalf = values.slice(Math.floor(values.length / 2)); + const earlierHalf = values.slice(0, Math.floor(values.length / 2)); + const recentAvg = recentHalf.reduce((a, b) => a + b, 0) / recentHalf.length; + const earlierAvg = + earlierHalf.reduce((a, b) => a + b, 0) / earlierHalf.length; + + const diff = recentAvg - earlierAvg; + if (target.targetDirection === "increase") { + trending = diff > 0 ? "toward" : diff < 0 ? "away" : "neutral"; + } else { + trending = diff < 0 ? "toward" : diff > 0 ? "away" : "neutral"; + } + } + + return { currentValue, progressPercent, trending }; +} + +type PlayerTargetWithCreator = PlayerTarget & { + creator: { name: string | null; email: string }; +}; + +export type TargetsServiceInterface = { + readonly getPlayerTargets: ( + teamId: number, + playerName: string + ) => Effect.Effect; + + readonly getTeamTargets: ( + teamId: number + ) => Effect.Effect< + Record, + TargetsQueryError + >; + + readonly getRecentScrimStats: ( + playerName: string, + teamId: number, + scrimCount: number + ) => Effect.Effect; +}; + +export class TargetsService extends Context.Tag( + "@app/data/player/TargetsService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getPlayerTargets( + teamId: number, + playerName: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId, playerName }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("playerName", playerName); + const targets = yield* Effect.tryPromise({ + try: () => + prisma.playerTarget.findMany({ + where: { + teamId, + playerName: { equals: playerName, mode: "insensitive" }, + }, + include: { creator: { select: { name: true, email: true } } }, + orderBy: { createdAt: "desc" }, + }), + catch: (error) => + new TargetsQueryError({ + operation: "fetch player targets", + cause: error, + }), + }).pipe( + Effect.withSpan("player.targets.fetchPlayerTargets", { + attributes: { teamId, playerName }, + }) + ); + + wideEvent.target_count = targets.length; + wideEvent.outcome = "success"; + yield* Metric.increment(playerTargetsQuerySuccessTotal); + return targets; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(playerTargetsQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getPlayerTargets") + : Effect.logInfo("player.getPlayerTargets"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + playerTargetsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getPlayerTargets") + ); + } + + function getTeamTargets( + teamId: number + ): Effect.Effect< + Record, + TargetsQueryError + > { + const startTime = Date.now(); + const wideEvent: Record = { teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const targets = yield* Effect.tryPromise({ + try: () => + prisma.playerTarget.findMany({ + where: { teamId }, + include: { creator: { select: { name: true, email: true } } }, + orderBy: { createdAt: "desc" }, + }), + catch: (error) => + new TargetsQueryError({ + operation: "fetch team targets", + cause: error, + }), + }).pipe( + Effect.withSpan("player.targets.fetchTeamTargets", { + attributes: { teamId }, + }) + ); + + const grouped: Record = {}; + for (const target of targets) { + if (!grouped[target.playerName]) { + grouped[target.playerName] = []; + } + grouped[target.playerName].push(target); + } + + wideEvent.target_count = targets.length; + wideEvent.player_count = Object.keys(grouped).length; + wideEvent.outcome = "success"; + yield* Metric.increment(teamTargetsQuerySuccessTotal); + return grouped; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(teamTargetsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getTeamTargets") + : Effect.logInfo("player.getTeamTargets"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + teamTargetsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getTeamTargets") + ); + } + + function getRecentScrimStats( + playerName: string, + teamId: number, + scrimCount: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + playerName, + teamId, + scrimCount, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("playerName", playerName); + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("scrimCount", scrimCount); + const recentScrims = yield* Effect.tryPromise({ + try: () => + prisma.scrim.findMany({ + where: { teamId }, + orderBy: { date: "desc" }, + take: scrimCount, + select: { + id: true, + date: true, + name: true, + maps: { + select: { + id: true, + mapData: { select: { id: true } }, + }, + }, + }, + }), + catch: (error) => + new TargetsQueryError({ + operation: "fetch recent scrims", + cause: error, + }), + }).pipe( + Effect.withSpan("player.targets.fetchRecentScrims", { + attributes: { teamId, scrimCount }, + }) + ); + + if (recentScrims.length === 0) { + wideEvent.scrim_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(recentScrimStatsQuerySuccessTotal); + const _empty: ScrimStatPoint[] = []; return _empty; + } + + const mapIds = recentScrims.flatMap((s) => + s.maps.flatMap((m) => m.mapData.map((md) => md.id)) + ); + + if (mapIds.length === 0) { + wideEvent.scrim_count = recentScrims.length; + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(recentScrimStatsQuerySuccessTotal); + const _empty: ScrimStatPoint[] = []; return _empty; + } + + const stats = yield* Effect.tryPromise({ + try: async () => { + const raw = await prisma.$queryRaw` + WITH maxTime AS ( + SELECT + MAX("match_time") AS max_time, + "MapDataId" + FROM + "PlayerStat" + WHERE + "MapDataId" IN (${Prisma.join(mapIds)}) + GROUP BY + "MapDataId" + ) + SELECT + ps.* + FROM + "PlayerStat" ps + INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" + WHERE + ps."MapDataId" IN (${Prisma.join(mapIds)}) + AND ps."player_name" ILIKE ${playerName}`; + return removeDuplicateRows(raw); + }, + catch: (error) => + new TargetsQueryError({ + operation: "fetch recent scrim player stats", + cause: error, + }), + }).pipe( + Effect.withSpan("player.targets.fetchScrimPlayerStats", { + attributes: { playerName, teamId, mapCount: mapIds.length }, + }) + ); + + const results: ScrimStatPoint[] = []; + for (const scrim of recentScrims) { + const scrimMapIds = new Set( + scrim.maps.flatMap((m) => m.mapData.map((md) => md.id)) + ); + const scrimStats = stats.filter((s) => scrimMapIds.has(s.MapDataId!)); + + if (scrimStats.length === 0) continue; + + const totalTime = scrimStats.reduce( + (sum, s) => sum + s.hero_time_played, + 0 + ); + const timeMins = toMins(totalTime); + if (timeMins <= 0) continue; + + const statKeys: TargetStatKey[] = [ + "eliminations", + "deaths", + "hero_damage_dealt", + "damage_taken", + "damage_blocked", + "final_blows", + "healing_dealt", + "ultimates_earned", + ]; + + const per10Stats: Record = {}; + for (const key of statKeys) { + const total = scrimStats.reduce( + (sum, s) => sum + (Number(s[key]) || 0), + 0 + ); + per10Stats[key] = (total / timeMins) * 10; + } + + results.push({ + scrimId: scrim.id, + scrimDate: scrim.date.toISOString(), + scrimName: scrim.name, + stats: per10Stats, + }); + } + + results.sort( + (a, b) => + new Date(a.scrimDate).getTime() - new Date(b.scrimDate).getTime() + ); + + wideEvent.scrim_count = recentScrims.length; + wideEvent.result_count = results.length; + wideEvent.outcome = "success"; + yield* Metric.increment(recentScrimStatsQuerySuccessTotal); + return results; + }).pipe( + Effect.tapError((error: TargetsQueryError) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(recentScrimStatsQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("player.getRecentScrimStats") + : Effect.logInfo("player.getRecentScrimStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + recentScrimStatsQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("player.getRecentScrimStats") + ); + } + + const playerTargetsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamId, playerName] = JSON.parse(key) as [number, string]; + return getPlayerTargets(teamId, playerName).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ); + }, + }); + + const teamTargetsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamId: number) => + getTeamTargets(teamId).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ), + }); + + const recentScrimStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [playerName, teamId, scrimCount] = JSON.parse(key) as [ + string, + number, + number, + ]; + return getRecentScrimStats(playerName, teamId, scrimCount).pipe( + Effect.tap(() => Metric.increment(playerCacheMissTotal)) + ); + }, + }); + + return { + getPlayerTargets: (teamId: number, playerName: string) => + playerTargetsCache + .get(JSON.stringify([teamId, playerName])) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + getTeamTargets: (teamId: number) => + teamTargetsCache + .get(teamId) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + getRecentScrimStats: ( + playerName: string, + teamId: number, + scrimCount: number + ) => + recentScrimStatsCache + .get(JSON.stringify([playerName, teamId, scrimCount])) + .pipe(Effect.tap(() => Metric.increment(playerCacheRequestTotal))), + } satisfies TargetsServiceInterface; + } +); + +export const TargetsServiceLive = Layer.effect(TargetsService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/player/types.ts b/src/data/player/types.ts new file mode 100644 index 000000000..6699dd01f --- /dev/null +++ b/src/data/player/types.ts @@ -0,0 +1,265 @@ +import type { ConfidenceMetadata } from "@/lib/confidence"; +import type { PlayerTarget } from "@prisma/client"; +import { Schema as S } from "effect"; + +export type MostPlayedHeroRow = { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; +}; + +export type ScoutingPlayerSummary = { + id: number; + name: string; + team: string; + role: string; + region: string; + status: string; + slug: string; +}; + +export type HeroFrequency = { + hero: string; + mapCount: number; +}; + +export type TournamentMatchEntry = { + date: Date; + opponent: string; + opponentFullName: string; + teamScore: number | null; + opponentScore: number | null; + result: "win" | "loss"; + heroesPlayed: string[]; +}; + +export type TournamentRecord = { + tournamentTitle: string; + teamName: string; + role: string; + wins: number; + losses: number; + winRate: number; + matches: TournamentMatchEntry[]; +}; + +export type PlayerProfile = { + id: number; + name: string; + team: string; + status: string; + role: string; + country: string; + signatureHeroes: string[]; + winnings: number; + region: string; + playerUrl: string; + slug: string; + heroFrequencies: HeroFrequency[]; + competitiveMapCount: number; + tournamentRecords: TournamentRecord[]; + totalTournaments: number; +}; + +export type { ValidStatColumn } from "@/lib/stat-percentiles"; + +export type HeroStatZScore = { + stat: string; + per10: number; + zScore: number | null; + heroAvgPer10: number; + heroStdPer10: number; + sampleSize: number; +}; + +export type ScoutingHeroPerformance = { + hero: string; + role: "Tank" | "Damage" | "Support"; + mapsPlayed: number; + totalTimePlayed: number; + stats: HeroStatZScore[]; + compositeZScore: number; +}; + +export type AdvancedMetrics = { + mvpScore: number; + fletaDeadliftPercentage: number; + firstPickPercentage: number; + firstPickCount: number; + firstDeathPercentage: number; + firstDeathCount: number; + fightReversalPercentage: number; + killsPerUltimate: number; + averageUltChargeTime: number; + averageDroughtTime: number; + duelWinratePercentage: number; + consistencyScore: number; +}; + +export type KillPatterns = { + topHeroesEliminated: { hero: string; count: number }[]; + topHeroesDiedTo: { hero: string; count: number }[]; + killMethods: { method: string; count: number }[]; +}; + +export type RoleDistributionEntry = { + role: string; + timePlayed: number; + percentage: number; +}; + +export type AccuracyStats = { + weaponAccuracy: number; + criticalHitAccuracy: number; + scopedAccuracy: number; +}; + +export type MapWinrateEntry = { + mapName: string; + mapType: string; + wins: number; + losses: number; + winRate: number; + played: number; +}; + +export type MapTypeWinrateEntry = { + mapType: string; + wins: number; + losses: number; + winRate: number; + played: number; +}; + +export type CompetitiveMapWinrates = { + byMapType: MapTypeWinrateEntry[]; + byMap: MapWinrateEntry[]; +}; + +export type ScrimMapWinrates = { + byMap: { mapName: string; wins: number; losses: number; winRate: number }[]; +}; + +export type ScrimData = { + available: true; + mapsPlayed: number; + totalTimePlayed: number; + heroes: ScoutingHeroPerformance[]; + advancedMetrics: AdvancedMetrics; + killPatterns: KillPatterns; + roleDistribution: RoleDistributionEntry[]; + accuracy: AccuracyStats; + kdRatio: number; + eliminationsPer10: number; + deathsPer10: number; + heroDamagePer10: number; + healingDealtPer10: number; +}; + +export type InsightItem = { + category: "hero" | "map" | "stat" | "combat"; + label: string; + detail: string; + value: number; +}; + +export type PlayerScoutingAnalytics = { + scrimData: ScrimData | null; + competitiveMapWinrates: CompetitiveMapWinrates; + scrimMapWinrates: ScrimMapWinrates | null; + strengths: InsightItem[]; + weaknesses: InsightItem[]; +}; + +export type { ConfidenceMetadata } from "@/lib/confidence"; + +export type PlayerHeroZScore = { + hero: string; + role: "Tank" | "Damage" | "Support"; + compositeZScore: number; + mapsPlayed: number; + totalTimePlayed: number; + isPrimary: boolean; +}; + +export type PlayerHeroDepth = { + playerName: string; + role: "Tank" | "Damage" | "Support"; + heroes: PlayerHeroZScore[]; + primarySecondaryDelta: number | null; + heroPoolSize: number; + confidence: ConfidenceMetadata; +}; + +export type HeroSubstitutionRate = { + playerName: string; + primaryHero: string; + totalMaps: number; + mapsOnPrimary: number; + mapsForced: number; + substitutionRate: number; + performanceDelta: number | null; +}; + +export type PlayerVulnerability = { + playerName: string; + role: "Tank" | "Damage" | "Support"; + primaryHero: string; + heroDepthDelta: number; + opponentBanRate: number; + opponentBanCount: number; + vulnerabilityIndex: number; + riskLevel: "critical" | "high" | "moderate" | "low"; +}; + +export type BestPlayerHighlight = { + playerName: string; + role: "Tank" | "Damage" | "Support"; + primaryHero: string; + compositeZScore: number; + mapsPlayed: number; + isTargetedByBans: boolean; + banTargetRate: number; +}; + +export type PlayerIntelligence = { + playerDepths: PlayerHeroDepth[]; + substitutionRates: HeroSubstitutionRate[]; + vulnerabilities: PlayerVulnerability[]; + bestPlayer: BestPlayerHighlight | null; +}; + +export type ScrimStatPoint = { + scrimId: number; + scrimDate: string; + scrimName: string; + stats: Record; +}; + +export type TargetProgress = { + target: PlayerTarget & { + creator: { name: string | null; email: string }; + }; + currentValue: number; + progressPercent: number; + trending: "toward" | "away" | "neutral"; +}; + +export const MapIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Map ID must be a positive integer" }) +); + +export const TeamIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Team ID must be a positive integer" }) +); + +export const PlayerNameSchema = S.String.pipe( + S.minLength(1, { message: () => "Player name must not be empty" }) +); + +export const SlugSchema = S.String.pipe( + S.minLength(1, { message: () => "Slug must not be empty" }) +); From 40a12322b134308aafe69481a9a1af882330215d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:22 -0400 Subject: [PATCH 12/42] Add map Effect services Covers heatmaps, killfeed analysis with calibration, replay visualization, tempo (Gaussian KDE), rotation death detection, and map group CRUD. Uses nested sub-domains for heatmap, killfeed, and replay. --- src/data/map/errors.ts | 35 ++ src/data/map/group-service.ts | 485 ++++++++++++++++++++ src/data/map/heatmap/index.ts | 11 + src/data/map/heatmap/service.ts | 400 +++++++++++++++++ src/data/map/heatmap/types.ts | 49 ++ src/data/map/index.ts | 87 ++++ src/data/map/killfeed/calibration.ts | 214 +++++++++ src/data/map/killfeed/index.ts | 26 ++ src/data/map/killfeed/service.ts | 352 +++++++++++++++ src/data/map/killfeed/types.ts | 64 +++ src/data/map/metrics.ts | 181 ++++++++ src/data/map/replay/index.ts | 13 + src/data/map/replay/service.ts | 594 +++++++++++++++++++++++++ src/data/map/replay/types.ts | 73 +++ src/data/map/rotation-death-service.ts | 407 +++++++++++++++++ src/data/map/tempo-service.ts | 389 ++++++++++++++++ src/data/map/types.ts | 32 ++ 17 files changed, 3412 insertions(+) create mode 100644 src/data/map/errors.ts create mode 100644 src/data/map/group-service.ts create mode 100644 src/data/map/heatmap/index.ts create mode 100644 src/data/map/heatmap/service.ts create mode 100644 src/data/map/heatmap/types.ts create mode 100644 src/data/map/index.ts create mode 100644 src/data/map/killfeed/calibration.ts create mode 100644 src/data/map/killfeed/index.ts create mode 100644 src/data/map/killfeed/service.ts create mode 100644 src/data/map/killfeed/types.ts create mode 100644 src/data/map/metrics.ts create mode 100644 src/data/map/replay/index.ts create mode 100644 src/data/map/replay/service.ts create mode 100644 src/data/map/replay/types.ts create mode 100644 src/data/map/rotation-death-service.ts create mode 100644 src/data/map/tempo-service.ts create mode 100644 src/data/map/types.ts diff --git a/src/data/map/errors.ts b/src/data/map/errors.ts new file mode 100644 index 000000000..cd38f022f --- /dev/null +++ b/src/data/map/errors.ts @@ -0,0 +1,35 @@ +import { Schema as S } from "effect"; + +export class MapQueryError extends S.TaggedError()( + "MapQueryError", + { + cause: S.Defect, + operation: S.String, + } +) { + get message(): string { + return `Map query failed: ${this.operation}`; + } +} + +export class MapNotFoundError extends S.TaggedError()( + "MapNotFoundError", + { + mapDataId: S.Number, + } +) { + get message(): string { + return `Map data not found: ${this.mapDataId}`; + } +} + +export class CalibrationNotFoundError extends S.TaggedError()( + "CalibrationNotFoundError", + { + mapName: S.String, + } +) { + get message(): string { + return `No calibration data found for map: ${this.mapName}`; + } +} diff --git a/src/data/map/group-service.ts b/src/data/map/group-service.ts new file mode 100644 index 000000000..947e0ffeb --- /dev/null +++ b/src/data/map/group-service.ts @@ -0,0 +1,485 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import prisma from "@/lib/prisma"; +import type { MapGroup } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "./errors"; +import { + mapCacheMissTotal, + mapCacheRequestTotal, + mapGroupMutationDuration, + mapGroupMutationErrorTotal, + mapGroupMutationSuccessTotal, + mapGroupQueryDuration, + mapGroupQueryErrorTotal, + mapGroupQuerySuccessTotal, +} from "./metrics"; + +export type MapGroupServiceInterface = { + readonly getMapGroupsForTeam: ( + teamId: number + ) => Effect.Effect; + + readonly getMapGroupById: ( + groupId: number + ) => Effect.Effect; + + readonly createMapGroup: (data: { + name: string; + description?: string; + teamId: number; + mapIds: number[]; + category?: string; + createdBy: string; + }) => Effect.Effect; + + readonly updateMapGroup: ( + groupId: number, + data: { + name?: string; + description?: string; + mapIds?: number[]; + category?: string; + } + ) => Effect.Effect; + + readonly deleteMapGroup: ( + groupId: number + ) => Effect.Effect; + + readonly getMapGroupsByCategory: ( + teamId: number, + category: string + ) => Effect.Effect; +}; + +export class MapGroupService extends Context.Tag( + "@app/data/map/MapGroupService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getMapGroupsForTeam( + teamId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const groups = yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.findMany({ + where: { teamId }, + orderBy: { createdAt: "desc" }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch map groups for team", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.getMapGroupsForTeam.query", { + attributes: { teamId }, + }) + ); + + wideEvent.group_count = groups.length; + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupQuerySuccessTotal); + return groups; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.getMapGroupsForTeam") + : Effect.logInfo("map.group.getMapGroupsForTeam"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(mapGroupQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("map.group.getMapGroupsForTeam") + ); + } + + function getMapGroupById( + groupId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { groupId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("groupId", groupId); + const group = yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.findUnique({ + where: { id: groupId }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch map group by id", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.getMapGroupById.query", { + attributes: { groupId }, + }) + ); + + wideEvent.found = group !== null; + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupQuerySuccessTotal); + return group; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.getMapGroupById") + : Effect.logInfo("map.group.getMapGroupById"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(mapGroupQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("map.group.getMapGroupById") + ); + } + + function createMapGroup(data: { + name: string; + description?: string; + teamId: number; + mapIds: number[]; + category?: string; + createdBy: string; + }): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId: data.teamId, + name: data.name, + mapIdCount: data.mapIds.length, + category: data.category, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", data.teamId); + const group = yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.create({ + data: { + name: data.name, + description: data.description, + teamId: data.teamId, + mapIds: data.mapIds, + category: data.category, + createdBy: data.createdBy, + }, + }), + catch: (error) => + new MapQueryError({ + operation: "create map group", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.createMapGroup.query", { + attributes: { teamId: data.teamId }, + }) + ); + + wideEvent.groupId = group.id; + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupMutationSuccessTotal); + return group; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupMutationErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.createMapGroup") + : Effect.logInfo("map.group.createMapGroup"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + mapGroupMutationDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.group.createMapGroup") + ); + } + + function updateMapGroup( + groupId: number, + data: { + name?: string; + description?: string; + mapIds?: number[]; + category?: string; + } + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + groupId, + hasName: data.name !== undefined, + hasMapIds: data.mapIds !== undefined, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("groupId", groupId); + const group = yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.update({ + where: { id: groupId }, + data: { + name: data.name, + description: data.description, + mapIds: data.mapIds, + category: data.category, + }, + }), + catch: (error) => + new MapQueryError({ + operation: "update map group", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.updateMapGroup.query", { + attributes: { groupId }, + }) + ); + + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupMutationSuccessTotal); + return group; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupMutationErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.updateMapGroup") + : Effect.logInfo("map.group.updateMapGroup"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + mapGroupMutationDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.group.updateMapGroup") + ); + } + + function deleteMapGroup( + groupId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { groupId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("groupId", groupId); + yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.delete({ + where: { id: groupId }, + }), + catch: (error) => + new MapQueryError({ + operation: "delete map group", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.deleteMapGroup.query", { + attributes: { groupId }, + }) + ); + + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupMutationSuccessTotal); + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupMutationErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.deleteMapGroup") + : Effect.logInfo("map.group.deleteMapGroup"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + mapGroupMutationDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.group.deleteMapGroup") + ); + } + + function getMapGroupsByCategory( + teamId: number, + category: string + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId, category }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("category", category); + const groups = yield* Effect.tryPromise({ + try: () => + prisma.mapGroup.findMany({ + where: { teamId, category }, + orderBy: { name: "asc" }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch map groups by category", + cause: error, + }), + }).pipe( + Effect.withSpan("map.group.getMapGroupsByCategory.query", { + attributes: { teamId, category }, + }) + ); + + wideEvent.group_count = groups.length; + wideEvent.outcome = "success"; + yield* Metric.increment(mapGroupQuerySuccessTotal); + return groups; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapGroupQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.group.getMapGroupsByCategory") + : Effect.logInfo("map.group.getMapGroupsByCategory"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(mapGroupQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("map.group.getMapGroupsByCategory") + ); + } + + const mapGroupsForTeamCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (teamId: number) => + getMapGroupsForTeam(teamId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + const mapGroupByIdCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (groupId: number) => + getMapGroupById(groupId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + const mapGroupsByCategoryCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamId, category] = JSON.parse(key) as [number, string]; + return getMapGroupsByCategory(teamId, category).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ); + }, + }); + + return { + getMapGroupsForTeam: (teamId: number) => + mapGroupsForTeamCache + .get(teamId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + getMapGroupById: (groupId: number) => + mapGroupByIdCache + .get(groupId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + createMapGroup, + updateMapGroup, + deleteMapGroup, + getMapGroupsByCategory: (teamId: number, category: string) => + mapGroupsByCategoryCache + .get(JSON.stringify([teamId, category])) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies MapGroupServiceInterface; + } +); + +export const MapGroupServiceLive = Layer.effect(MapGroupService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/map/heatmap/index.ts b/src/data/map/heatmap/index.ts new file mode 100644 index 000000000..40441ec4a --- /dev/null +++ b/src/data/map/heatmap/index.ts @@ -0,0 +1,11 @@ +export { HeatmapService, HeatmapServiceLive } from "./service"; +export type { HeatmapServiceInterface } from "./service"; + +export type { + KillPoint, + HeatmapSubMap, + HeatmapData, + TimedCoord, + TimedKillCoord, + EventsByCategory, +} from "./types"; diff --git a/src/data/map/heatmap/service.ts b/src/data/map/heatmap/service.ts new file mode 100644 index 000000000..cddead69c --- /dev/null +++ b/src/data/map/heatmap/service.ts @@ -0,0 +1,400 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { + getControlSubMapName, + getControlSubMapNames, + isControlMap, +} from "@/lib/map-calibration/control-map-index"; +import { loadCalibration } from "@/lib/map-calibration/load-calibration"; +import type { MapTransform } from "@/lib/map-calibration/types"; +import { worldToImage } from "@/lib/map-calibration/world-to-image"; +import prisma from "@/lib/prisma"; +import { $Enums } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "../errors"; +import { + heatmapQueryDuration, + heatmapQueryErrorTotal, + heatmapQuerySuccessTotal, + mapCacheMissTotal, + mapCacheRequestTotal, +} from "../metrics"; +import type { + EventsByCategory, + HeatmapData, + HeatmapSubMap, + KillPoint, + TimedCoord, + TimedKillCoord, +} from "./types"; + +type Point = { u: number; v: number }; + +function toImagePoint( + x: number | null, + z: number | null, + transform: MapTransform +): Point | null { + if (x == null || z == null) return null; + return worldToImage({ x, y: z }, transform); +} + +function convertPoints(events: TimedCoord[], transform: MapTransform): Point[] { + const result: Point[] = []; + for (const e of events) { + const p = toImagePoint(e.x, e.z, transform); + if (p) result.push(p); + } + return result; +} + +function convertKillPoints( + events: TimedKillCoord[], + transform: MapTransform, + team1Name: string +): KillPoint[] { + const result: KillPoint[] = []; + for (const e of events) { + const p = toImagePoint(e.x, e.z, transform); + if (p) { + result.push({ + ...p, + team: e.victim_team === team1Name ? 1 : 2, + attackerName: e.attacker_name, + attackerHero: e.attacker_hero, + victimName: e.victim_name, + victimHero: e.victim_hero, + ability: e.event_ability, + matchTime: e.match_time, + }); + } + } + return result; +} + +function buildSubMap( + calibrationMapName: string, + cal: NonNullable>>, + events: EventsByCategory, + team1Name: string +): HeatmapSubMap { + const colonIdx = calibrationMapName.indexOf(": "); + const displayName = + colonIdx >= 0 ? calibrationMapName.slice(colonIdx + 2) : calibrationMapName; + + return { + subMapName: displayName, + calibrationMapName, + imagePresignedUrl: cal.imagePresignedUrl, + imageWidth: cal.imageWidth, + imageHeight: cal.imageHeight, + damagePoints: convertPoints(events.damage, cal.transform), + healingPoints: convertPoints(events.healing, cal.transform), + killPoints: convertKillPoints(events.kills, cal.transform, team1Name), + }; +} + +function assignToRound( + matchTime: number, + roundStarts: { match_time: number; objective_index: number }[] +): number { + for (let i = roundStarts.length - 1; i >= 0; i--) { + if (matchTime >= roundStarts[i].match_time) { + return roundStarts[i].objective_index; + } + } + return 0; +} + +export type HeatmapServiceInterface = { + readonly getHeatmapData: ( + mapDataId: number + ) => Effect.Effect; +}; + +export class HeatmapService extends Context.Tag("@app/data/map/HeatmapService")< + HeatmapService, + HeatmapServiceInterface +>() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function fetchEvents( + mapDataId: number + ): Effect.Effect { + return Effect.tryPromise({ + try: () => + Promise.all([ + prisma.damage.findMany({ + where: { MapDataId: mapDataId }, + select: { match_time: true, victim_x: true, victim_z: true }, + }), + prisma.healing.findMany({ + where: { MapDataId: mapDataId }, + select: { match_time: true, healer_x: true, healer_z: true }, + }), + prisma.kill.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + victim_x: true, + victim_z: true, + victim_team: true, + attacker_name: true, + attacker_hero: true, + victim_name: true, + victim_hero: true, + event_ability: true, + }, + }), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch heatmap events", + cause: error, + }), + }).pipe( + Effect.map(([damageRows, healingRows, killRows]) => ({ + damage: damageRows.map((r) => ({ + match_time: r.match_time, + x: r.victim_x, + z: r.victim_z, + })), + healing: healingRows.map((r) => ({ + match_time: r.match_time, + x: r.healer_x, + z: r.healer_z, + })), + kills: killRows.map((r) => ({ + match_time: r.match_time, + x: r.victim_x, + z: r.victim_z, + victim_team: r.victim_team, + attacker_name: r.attacker_name, + attacker_hero: r.attacker_hero, + victim_name: r.victim_name, + victim_hero: r.victim_hero, + event_ability: r.event_ability, + })), + })), + Effect.withSpan("map.heatmap.fetchEvents", { + attributes: { mapDataId }, + }) + ); + } + + function getControlHeatmapData( + mapName: string, + mapDataId: number, + events: EventsByCategory, + team1Name: string + ): Effect.Effect { + return Effect.gen(function* () { + const roundStarts = yield* Effect.tryPromise({ + try: () => + prisma.roundStart.findMany({ + where: { MapDataId: mapDataId }, + select: { match_time: true, objective_index: true }, + orderBy: { match_time: "asc" }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch round starts for control heatmap", + cause: error, + }), + }); + + function splitBySubMap( + coords: TimedCoord[] + ): Map { + const result = new Map(); + for (const c of coords) { + const idx = assignToRound(c.match_time, roundStarts); + const name = getControlSubMapName(mapName, idx); + if (!name) continue; + const arr = result.get(name) ?? []; + arr.push(c); + result.set(name, arr); + } + return result; + } + + const damageBySubMap = splitBySubMap(events.damage); + const healingBySubMap = splitBySubMap(events.healing); + const killsBySubMap = splitBySubMap(events.kills); + + const allSubMapNames = getControlSubMapNames(mapName); + const subMaps: HeatmapSubMap[] = []; + + for (const calibrationMapName of allSubMapNames) { + const cal = yield* Effect.tryPromise({ + try: () => loadCalibration(calibrationMapName), + catch: (error) => + new MapQueryError({ + operation: "load calibration for sub-map", + cause: error, + }), + }); + if (!cal) continue; + + const subEvents: EventsByCategory = { + damage: damageBySubMap.get(calibrationMapName) ?? [], + healing: healingBySubMap.get(calibrationMapName) ?? [], + kills: (killsBySubMap.get(calibrationMapName) ?? + []) as TimedKillCoord[], + }; + + subMaps.push( + buildSubMap(calibrationMapName, cal, subEvents, team1Name) + ); + } + + if (subMaps.length === 0) return { type: "no_calibration" } as const; + + return { type: "control", subMaps } as const; + }); + } + + function getHeatmapData( + mapDataId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapDataId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapDataId", mapDataId); + const matchStart = yield* Effect.tryPromise({ + try: () => + prisma.matchStart.findFirst({ + where: { MapDataId: mapDataId }, + select: { map_name: true, map_type: true, team_1_name: true }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch match start for heatmap", + cause: error, + }), + }); + + if (!matchStart) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_calibration"; + yield* Metric.increment(heatmapQuerySuccessTotal); + return { type: "no_calibration" } as const; + } + + wideEvent.map_name = matchStart.map_name; + wideEvent.map_type = matchStart.map_type; + + const events = yield* fetchEvents(mapDataId); + + const hasCoords = + events.damage.some((e) => e.x != null && e.z != null) || + events.healing.some((e) => e.x != null && e.z != null) || + events.kills.some((e) => e.x != null && e.z != null); + + if (!hasCoords) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_coordinates"; + yield* Metric.increment(heatmapQuerySuccessTotal); + return { type: "no_coordinates" } as const; + } + + wideEvent.damage_count = events.damage.length; + wideEvent.healing_count = events.healing.length; + wideEvent.kill_count = events.kills.length; + + if ( + matchStart.map_type === $Enums.MapType.Control && + isControlMap(matchStart.map_name) + ) { + const result = yield* getControlHeatmapData( + matchStart.map_name, + mapDataId, + events, + matchStart.team_1_name + ); + wideEvent.outcome = "success"; + wideEvent.result_type = result.type; + yield* Metric.increment(heatmapQuerySuccessTotal); + return result; + } + + const cal = yield* Effect.tryPromise({ + try: () => loadCalibration(matchStart.map_name), + catch: (error) => + new MapQueryError({ + operation: "load calibration for map", + cause: error, + }), + }); + if (!cal) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_calibration"; + yield* Metric.increment(heatmapQuerySuccessTotal); + return { type: "no_calibration" } as const; + } + + wideEvent.outcome = "success"; + wideEvent.result_type = "single"; + yield* Metric.increment(heatmapQuerySuccessTotal); + + return { + type: "single", + subMap: buildSubMap( + matchStart.map_name, + cal, + events, + matchStart.team_1_name + ), + } as const; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heatmapQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.heatmap.getHeatmapData") + : Effect.logInfo("map.heatmap.getHeatmapData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heatmapQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("map.heatmap.getHeatmapData") + ); + } + + const heatmapCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapDataId: number) => + getHeatmapData(mapDataId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getHeatmapData: (mapDataId: number) => + heatmapCache + .get(mapDataId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies HeatmapServiceInterface; + } +); + +export const HeatmapServiceLive = Layer.effect(HeatmapService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/map/heatmap/types.ts b/src/data/map/heatmap/types.ts new file mode 100644 index 000000000..ed077ca28 --- /dev/null +++ b/src/data/map/heatmap/types.ts @@ -0,0 +1,49 @@ +type Point = { u: number; v: number }; + +export type KillPoint = Point & { + team: 1 | 2; + attackerName: string; + attackerHero: string; + victimName: string; + victimHero: string; + ability: string; + matchTime: number; +}; + +export type HeatmapSubMap = { + subMapName: string; + calibrationMapName: string; + imagePresignedUrl: string; + imageWidth: number; + imageHeight: number; + damagePoints: Point[]; + healingPoints: Point[]; + killPoints: KillPoint[]; +}; + +export type HeatmapData = + | { type: "single"; subMap: HeatmapSubMap } + | { type: "control"; subMaps: HeatmapSubMap[] } + | { type: "no_calibration" } + | { type: "no_coordinates" }; + +export type TimedCoord = { + match_time: number; + x: number | null; + z: number | null; +}; + +export type TimedKillCoord = TimedCoord & { + victim_team: string; + attacker_name: string; + attacker_hero: string; + victim_name: string; + victim_hero: string; + event_ability: string; +}; + +export type EventsByCategory = { + damage: TimedCoord[]; + healing: TimedCoord[]; + kills: TimedKillCoord[]; +}; diff --git a/src/data/map/index.ts b/src/data/map/index.ts new file mode 100644 index 000000000..2c143564a --- /dev/null +++ b/src/data/map/index.ts @@ -0,0 +1,87 @@ +export { HeatmapService, HeatmapServiceLive } from "./heatmap"; +export type { HeatmapServiceInterface } from "./heatmap"; +export type { + KillPoint, + HeatmapSubMap, + HeatmapData, + TimedCoord, + TimedKillCoord, + EventsByCategory, +} from "./heatmap"; + +export { + KillfeedService, + KillfeedServiceLive, + hasAnyUltFeature, + mergeKillfeedEvents, + getEventTime, + isKillDuringUlt, + KillfeedCalibrationService, + KillfeedCalibrationServiceLive, + serializeCalibrationData, + DEFAULT_KILLFEED_OPTIONS, +} from "./killfeed"; +export type { + KillfeedServiceInterface, + KillfeedCalibrationServiceInterface, +} from "./killfeed"; +export type { + UltimateSpan, + FightUltimateData, + KillfeedDisplayOptions, + KillfeedEvent, + KillfeedCalibrationData, + SerializedCalibrationData, +} from "./killfeed"; + +export { ReplayService, ReplayServiceLive } from "./replay"; +export type { ReplayServiceInterface } from "./replay"; +export type { + PositionSample, + KillDisplayEvent, + UltDisplayEvent, + HeroSwapDisplayEvent, + RoundDisplayEvent, + DisplayEvent, + ReplayCalibration, + ReplayData, +} from "./replay"; + +export { + TempoService, + TempoServiceLive, + computeTempoSeries, + fightsToBoundaries, + tempoPointsToSvgPath, +} from "./tempo-service"; +export type { + TempoServiceInterface, + TempoDataPoint, + UltPin, + FightBoundary, + KillPin, + TempoChartData, +} from "./tempo-service"; + +export { + RotationDeathService, + RotationDeathServiceLive, +} from "./rotation-death-service"; +export type { RotationDeathServiceInterface } from "./rotation-death-service"; + +export { MapGroupService, MapGroupServiceLive } from "./group-service"; +export type { MapGroupServiceInterface } from "./group-service"; + +export { + MapQueryError, + MapNotFoundError, + CalibrationNotFoundError, +} from "./errors"; + +export { + MapDataIdSchema, + MapIdSchema, + TeamIdSchema, + MapGroupCreateSchema, + MapGroupUpdateSchema, +} from "./types"; diff --git a/src/data/map/killfeed/calibration.ts b/src/data/map/killfeed/calibration.ts new file mode 100644 index 000000000..92993312f --- /dev/null +++ b/src/data/map/killfeed/calibration.ts @@ -0,0 +1,214 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { + getControlSubMapName, + isControlMap, +} from "@/lib/map-calibration/control-map-index"; +import { + loadCalibration, + type LoadedCalibration, +} from "@/lib/map-calibration/load-calibration"; +import prisma from "@/lib/prisma"; +import { $Enums } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "../errors"; +import { + killfeedCalibrationQueryDuration, + killfeedCalibrationQueryErrorTotal, + killfeedCalibrationQuerySuccessTotal, + mapCacheMissTotal, + mapCacheRequestTotal, +} from "../metrics"; +import type { + KillfeedCalibrationData, + SerializedCalibrationData, +} from "./types"; + +export function serializeCalibrationData( + data: KillfeedCalibrationData | null +): SerializedCalibrationData { + if (!data) return null; + const obj: Record = {}; + for (const [key, val] of data.calibrations) { + obj[key] = val; + } + return { + calibrations: obj, + mapName: data.mapName, + mapType: data.mapType, + roundStarts: data.roundStarts, + }; +} + +export type KillfeedCalibrationServiceInterface = { + readonly getKillfeedCalibration: ( + mapDataId: number + ) => Effect.Effect; +}; + +export class KillfeedCalibrationService extends Context.Tag( + "@app/data/map/KillfeedCalibrationService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = + Effect.gen(function* () { + function getKillfeedCalibration( + mapDataId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapDataId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapDataId", mapDataId); + const matchStart = yield* Effect.tryPromise({ + try: () => + prisma.matchStart.findFirst({ + where: { MapDataId: mapDataId }, + select: { map_name: true, map_type: true }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch match start for killfeed calibration", + cause: error, + }), + }).pipe( + Effect.withSpan("map.killfeed.calibration.fetchMatchStart", { + attributes: { mapDataId }, + }) + ); + + if (!matchStart) { + wideEvent.outcome = "success"; + wideEvent.result = "no_match_start"; + yield* Metric.increment(killfeedCalibrationQuerySuccessTotal); + return null; + } + + wideEvent.map_name = matchStart.map_name; + wideEvent.map_type = matchStart.map_type; + + const calibrations = new Map(); + + if ( + matchStart.map_type === $Enums.MapType.Control && + isControlMap(matchStart.map_name) + ) { + const roundStarts = yield* Effect.tryPromise({ + try: () => + prisma.roundStart.findMany({ + where: { MapDataId: mapDataId }, + select: { match_time: true, objective_index: true }, + orderBy: { match_time: "asc" }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch round starts for killfeed calibration", + cause: error, + }), + }); + + const seenIndices = new Set(); + for (const rs of roundStarts) { + seenIndices.add(rs.objective_index); + } + + for (const idx of seenIndices) { + const subMapName = getControlSubMapName(matchStart.map_name, idx); + if (!subMapName) continue; + const cal = yield* Effect.tryPromise({ + try: () => loadCalibration(subMapName), + catch: (error) => + new MapQueryError({ + operation: "load calibration for sub-map", + cause: error, + }), + }); + if (cal) calibrations.set(subMapName, cal); + } + + wideEvent.calibration_count = calibrations.size; + wideEvent.outcome = "success"; + yield* Metric.increment(killfeedCalibrationQuerySuccessTotal); + + return { + calibrations, + mapName: matchStart.map_name, + mapType: matchStart.map_type, + roundStarts, + }; + } + + const cal = yield* Effect.tryPromise({ + try: () => loadCalibration(matchStart.map_name), + catch: (error) => + new MapQueryError({ + operation: "load calibration for map", + cause: error, + }), + }); + if (cal) calibrations.set(matchStart.map_name, cal); + + wideEvent.calibration_count = calibrations.size; + wideEvent.outcome = "success"; + yield* Metric.increment(killfeedCalibrationQuerySuccessTotal); + + return { + calibrations, + mapName: matchStart.map_name, + mapType: matchStart.map_type, + roundStarts: [], + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(killfeedCalibrationQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.killfeed.getKillfeedCalibration") + : Effect.logInfo("map.killfeed.getKillfeedCalibration"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + killfeedCalibrationQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.killfeed.getKillfeedCalibration") + ); + } + + const calibrationCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapDataId: number) => + getKillfeedCalibration(mapDataId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getKillfeedCalibration: (mapDataId: number) => + calibrationCache + .get(mapDataId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies KillfeedCalibrationServiceInterface; + }); + +export const KillfeedCalibrationServiceLive = Layer.effect( + KillfeedCalibrationService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/map/killfeed/index.ts b/src/data/map/killfeed/index.ts new file mode 100644 index 000000000..bac041692 --- /dev/null +++ b/src/data/map/killfeed/index.ts @@ -0,0 +1,26 @@ +export { + KillfeedService, + KillfeedServiceLive, + hasAnyUltFeature, + mergeKillfeedEvents, + getEventTime, + isKillDuringUlt, +} from "./service"; +export type { KillfeedServiceInterface } from "./service"; + +export { + KillfeedCalibrationService, + KillfeedCalibrationServiceLive, + serializeCalibrationData, +} from "./calibration"; +export type { KillfeedCalibrationServiceInterface } from "./calibration"; + +export type { + UltimateSpan, + FightUltimateData, + KillfeedDisplayOptions, + KillfeedEvent, + KillfeedCalibrationData, + SerializedCalibrationData, +} from "./types"; +export { DEFAULT_KILLFEED_OPTIONS } from "./types"; diff --git a/src/data/map/killfeed/service.ts b/src/data/map/killfeed/service.ts new file mode 100644 index 000000000..ead19732e --- /dev/null +++ b/src/data/map/killfeed/service.ts @@ -0,0 +1,352 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { groupKillsIntoFights, type Fight } from "@/lib/utils"; +import type { Kill } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "../errors"; +import { + killfeedUltSpansQueryDuration, + killfeedUltSpansQueryErrorTotal, + killfeedUltSpansQuerySuccessTotal, + mapCacheMissTotal, + mapCacheRequestTotal, +} from "../metrics"; +import type { + FightUltimateData, + KillfeedDisplayOptions, + KillfeedEvent, + UltimateSpan, +} from "./types"; + +const INSTANT_ULT_THRESHOLD = 1.0; +const NO_END_ULT_WINDOW = 5.0; + +function assignNestingDepths(spans: UltimateSpan[]): void { + const sorted = spans.sort((a, b) => a.startTime - b.startTime); + const activeEndTimes: number[] = []; + + for (const span of sorted) { + while ( + activeEndTimes.length > 0 && + activeEndTimes[activeEndTimes.length - 1] <= span.startTime + ) { + activeEndTimes.pop(); + } + + span.depth = activeEndTimes.length; + + let insertIdx = activeEndTimes.length; + for (let i = activeEndTimes.length - 1; i >= 0; i--) { + if (activeEndTimes[i] <= span.endTime) break; + insertIdx = i; + } + activeEndTimes.splice(insertIdx, 0, span.endTime); + } +} + +function assignSpanToFight(span: UltimateSpan, fights: Fight[]): number | null { + for (let i = 0; i < fights.length; i++) { + const fight = fights[i]; + if (span.startTime <= fight.end && span.endTime >= fight.start) { + return i; + } + } + return null; +} + +export function hasAnyUltFeature(options: KillfeedDisplayOptions): boolean { + return ( + options.showUltBrackets || + options.showUltStartEvents || + options.showUltEndEvents || + options.showUltKillHighlights + ); +} + +export function getEventTime(event: KillfeedEvent): number { + if (event.type === "kill") return event.data.match_time; + if (event.type === "ult_start") return event.data.startTime; + if (event.type === "ult_instant") return event.data.startTime; + return event.data.endTime; +} + +export function mergeKillfeedEvents( + kills: Kill[], + spans: UltimateSpan[], + options: KillfeedDisplayOptions +): KillfeedEvent[] { + const events: KillfeedEvent[] = kills.map((k) => ({ + type: "kill" as const, + data: k, + })); + + const showStartRows = options.showUltStartEvents; + const showEndRows = options.showUltEndEvents; + + if (showStartRows || showEndRows) { + for (const span of spans) { + if (span.isInstant) { + if (showStartRows || showEndRows) { + events.push({ type: "ult_instant", data: span }); + } + } else { + if (showStartRows) { + events.push({ type: "ult_start", data: span }); + } + if (showEndRows) { + events.push({ type: "ult_end", data: span }); + } + } + } + } + + events.sort((a, b) => { + const timeA = getEventTime(a); + const timeB = getEventTime(b); + + if (timeA !== timeB) return timeA - timeB; + + const priority = { ult_start: 0, kill: 1, ult_end: 2, ult_instant: 0 }; + return priority[a.type] - priority[b.type]; + }); + + return events; +} + +export function isKillDuringUlt( + kill: Kill, + spans: UltimateSpan[] +): UltimateSpan | null { + for (const span of spans) { + if ( + kill.match_time >= span.startTime && + kill.match_time <= span.endTime && + kill.attacker_team === span.playerTeam + ) { + return span; + } + } + return null; +} + +export type KillfeedServiceInterface = { + readonly getUltimateSpans: ( + mapId: number + ) => Effect.Effect; +}; + +export class KillfeedService extends Context.Tag( + "@app/data/map/KillfeedService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getUltimateSpans( + mapId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new MapQueryError({ + operation: "resolve map data id for killfeed", + cause: error, + }), + }); + + wideEvent.mapDataId = mapDataId; + + const [ultimateStarts, ultimateEnds, kills, fights] = + yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.ultimateStart.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { match_time: "asc" }, + }), + prisma.ultimateEnd.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { match_time: "asc" }, + }), + prisma.kill.findMany({ + where: { MapDataId: mapDataId }, + orderBy: { match_time: "asc" }, + }), + groupKillsIntoFights(mapId), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch killfeed events and fights", + cause: error, + }), + }).pipe( + Effect.withSpan("map.killfeed.fetchEventsAndFights", { + attributes: { mapId, mapDataId }, + }) + ); + + if (fights.length === 0) { + wideEvent.fight_count = 0; + wideEvent.outcome = "success"; + yield* Metric.increment(killfeedUltSpansQuerySuccessTotal); + const _empty: FightUltimateData[] = []; return _empty; + } + + wideEvent.ult_start_count = ultimateStarts.length; + wideEvent.ult_end_count = ultimateEnds.length; + wideEvent.kill_count = kills.length; + wideEvent.fight_count = fights.length; + + const spans: UltimateSpan[] = []; + const pairedEndIds = new Set(); + + for (const start of ultimateStarts) { + const end = ultimateEnds.find( + (e) => + !pairedEndIds.has(e.id) && + e.ultimate_id === start.ultimate_id && + e.player_name === start.player_name && + e.match_time >= start.match_time + ); + + if (end) { + pairedEndIds.add(end.id); + } + + const hasEnd = !!end; + const rawEndTime = end?.match_time ?? start.match_time; + const duration = rawEndTime - start.match_time; + const isInstant = !hasEnd || duration <= INSTANT_ULT_THRESHOLD; + + const effectiveEnd = hasEnd + ? rawEndTime + : start.match_time + NO_END_ULT_WINDOW; + + const killsDuringUlt = kills.filter( + (k) => + k.attacker_name === start.player_name && + k.match_time >= start.match_time && + k.match_time <= effectiveEnd + ); + + const deathsDuringUlt = kills.filter( + (k) => + k.victim_name === start.player_name && + k.match_time >= start.match_time && + k.match_time <= effectiveEnd + ); + + const diedDuringUlt = hasEnd + ? deathsDuringUlt.some((k) => k.match_time === rawEndTime) + : false; + + spans.push({ + id: start.id, + ultimateId: start.ultimate_id, + playerName: start.player_name, + playerTeam: start.player_team, + playerHero: start.player_hero, + startTime: start.match_time, + endTime: effectiveEnd, + duration, + depth: 0, + isInstant, + diedDuringUlt, + killsDuringUlt, + deathsDuringUlt, + }); + } + + const fightDataMap = new Map(); + + for (let i = 0; i < fights.length; i++) { + fightDataMap.set(i, { + fightIndex: i, + fightStart: fights[i].start, + fightEnd: fights[i].end, + spans: [], + }); + } + + for (const span of spans) { + const fightIdx = assignSpanToFight(span, fights); + if (fightIdx !== null) { + fightDataMap.get(fightIdx)!.spans.push(span); + } + } + + const result: FightUltimateData[] = []; + for (const [, fightData] of fightDataMap) { + if (fightData.spans.length > 0) { + assignNestingDepths(fightData.spans); + } + result.push(fightData); + } + + const sorted = result.sort((a, b) => a.fightIndex - b.fightIndex); + + wideEvent.span_count = spans.length; + wideEvent.outcome = "success"; + yield* Metric.increment(killfeedUltSpansQuerySuccessTotal); + + return sorted; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(killfeedUltSpansQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.killfeed.getUltimateSpans") + : Effect.logInfo("map.killfeed.getUltimateSpans"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + killfeedUltSpansQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.killfeed.getUltimateSpans") + ); + } + + const ultSpansCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapId: number) => + getUltimateSpans(mapId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getUltimateSpans: (mapId: number) => + ultSpansCache + .get(mapId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies KillfeedServiceInterface; + } +); + +export const KillfeedServiceLive = Layer.effect(KillfeedService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/map/killfeed/types.ts b/src/data/map/killfeed/types.ts new file mode 100644 index 000000000..36691a827 --- /dev/null +++ b/src/data/map/killfeed/types.ts @@ -0,0 +1,64 @@ +import type { Kill } from "@prisma/client"; +import type { LoadedCalibration } from "@/lib/map-calibration/load-calibration"; +import type { $Enums } from "@prisma/client"; + +export type UltimateSpan = { + id: number; + ultimateId: number; + playerName: string; + playerTeam: string; + playerHero: string; + startTime: number; + endTime: number; + duration: number; + depth: number; + isInstant: boolean; + diedDuringUlt: boolean; + killsDuringUlt: Kill[]; + deathsDuringUlt: Kill[]; +}; + +export type FightUltimateData = { + fightIndex: number; + fightStart: number; + fightEnd: number; + spans: UltimateSpan[]; +}; + +export type KillfeedDisplayOptions = { + showTimeline: boolean; + showUltBrackets: boolean; + showUltLabels: boolean; + showUltStartEvents: boolean; + showUltEndEvents: boolean; + showUltKillHighlights: boolean; +}; + +export type KillfeedEvent = + | { type: "kill"; data: Kill } + | { type: "ult_start"; data: UltimateSpan } + | { type: "ult_end"; data: UltimateSpan } + | { type: "ult_instant"; data: UltimateSpan }; + +export const DEFAULT_KILLFEED_OPTIONS: KillfeedDisplayOptions = { + showTimeline: false, + showUltBrackets: false, + showUltLabels: false, + showUltStartEvents: false, + showUltEndEvents: false, + showUltKillHighlights: false, +}; + +export type KillfeedCalibrationData = { + calibrations: Map; + mapName: string; + mapType: $Enums.MapType; + roundStarts: { match_time: number; objective_index: number }[]; +}; + +export type SerializedCalibrationData = { + calibrations: Record; + mapName: string; + mapType: string; + roundStarts: { match_time: number; objective_index: number }[]; +} | null; diff --git a/src/data/map/metrics.ts b/src/data/map/metrics.ts new file mode 100644 index 000000000..d20adbed7 --- /dev/null +++ b/src/data/map/metrics.ts @@ -0,0 +1,181 @@ +import { Metric, MetricBoundaries } from "effect"; + +export const heatmapQuerySuccessTotal = Metric.counter( + "map.heatmap.query.success", + { + description: "Total successful heatmap data queries", + incremental: true, + } +); + +export const heatmapQueryErrorTotal = Metric.counter( + "map.heatmap.query.error", + { + description: "Total heatmap data query failures", + incremental: true, + } +); + +export const heatmapQueryDuration = Metric.histogram( + "map.heatmap.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of heatmap data query duration in milliseconds" +); + +export const killfeedUltSpansQuerySuccessTotal = Metric.counter( + "map.killfeed.ult_spans.query.success", + { + description: "Total successful killfeed ultimate spans queries", + incremental: true, + } +); + +export const killfeedUltSpansQueryErrorTotal = Metric.counter( + "map.killfeed.ult_spans.query.error", + { + description: "Total killfeed ultimate spans query failures", + incremental: true, + } +); + +export const killfeedUltSpansQueryDuration = Metric.histogram( + "map.killfeed.ult_spans.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of killfeed ultimate spans query duration in milliseconds" +); + +export const killfeedCalibrationQuerySuccessTotal = Metric.counter( + "map.killfeed.calibration.query.success", + { + description: "Total successful killfeed calibration queries", + incremental: true, + } +); + +export const killfeedCalibrationQueryErrorTotal = Metric.counter( + "map.killfeed.calibration.query.error", + { + description: "Total killfeed calibration query failures", + incremental: true, + } +); + +export const killfeedCalibrationQueryDuration = Metric.histogram( + "map.killfeed.calibration.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of killfeed calibration query duration in milliseconds" +); + +export const replayDataQuerySuccessTotal = Metric.counter( + "map.replay.data.query.success", + { + description: "Total successful replay data queries", + incremental: true, + } +); + +export const replayDataQueryErrorTotal = Metric.counter( + "map.replay.data.query.error", + { + description: "Total replay data query failures", + incremental: true, + } +); + +export const replayDataQueryDuration = Metric.histogram( + "map.replay.data.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of replay data query duration in milliseconds" +); + +export const tempoQuerySuccessTotal = Metric.counter( + "map.tempo.query.success", + { + description: "Total successful tempo chart data queries", + incremental: true, + } +); + +export const tempoQueryErrorTotal = Metric.counter("map.tempo.query.error", { + description: "Total tempo chart data query failures", + incremental: true, +}); + +export const tempoQueryDuration = Metric.histogram( + "map.tempo.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of tempo chart data query duration in milliseconds" +); + +export const rotationDeathQuerySuccessTotal = Metric.counter( + "map.rotation_death.query.success", + { + description: "Total successful rotation death analysis queries", + incremental: true, + } +); + +export const rotationDeathQueryErrorTotal = Metric.counter( + "map.rotation_death.query.error", + { + description: "Total rotation death analysis query failures", + incremental: true, + } +); + +export const rotationDeathQueryDuration = Metric.histogram( + "map.rotation_death.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of rotation death analysis query duration in milliseconds" +); + +export const mapGroupQuerySuccessTotal = Metric.counter( + "map.group.query.success", + { + description: "Total successful map group queries", + incremental: true, + } +); + +export const mapGroupQueryErrorTotal = Metric.counter("map.group.query.error", { + description: "Total map group query failures", + incremental: true, +}); + +export const mapGroupQueryDuration = Metric.histogram( + "map.group.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of map group query duration in milliseconds" +); + +export const mapGroupMutationSuccessTotal = Metric.counter( + "map.group.mutation.success", + { + description: "Total successful map group mutations", + incremental: true, + } +); + +export const mapGroupMutationErrorTotal = Metric.counter( + "map.group.mutation.error", + { + description: "Total map group mutation failures", + incremental: true, + } +); + +export const mapGroupMutationDuration = Metric.histogram( + "map.group.mutation.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of map group mutation duration in milliseconds" +); + +export const mapCacheRequestTotal = Metric.counter("map.cache.request", { + description: "Total map data cache requests", + incremental: true, +}); + +export const mapCacheMissTotal = Metric.counter("map.cache.miss", { + description: "Total map data cache misses (triggered lookup)", + incremental: true, +}); diff --git a/src/data/map/replay/index.ts b/src/data/map/replay/index.ts new file mode 100644 index 000000000..b8a6f8cd4 --- /dev/null +++ b/src/data/map/replay/index.ts @@ -0,0 +1,13 @@ +export { ReplayService, ReplayServiceLive } from "./service"; +export type { ReplayServiceInterface } from "./service"; + +export type { + PositionSample, + KillDisplayEvent, + UltDisplayEvent, + HeroSwapDisplayEvent, + RoundDisplayEvent, + DisplayEvent, + ReplayCalibration, + ReplayData, +} from "./types"; diff --git a/src/data/map/replay/service.ts b/src/data/map/replay/service.ts new file mode 100644 index 000000000..ebbb53d3b --- /dev/null +++ b/src/data/map/replay/service.ts @@ -0,0 +1,594 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { + getControlSubMapNames, + isControlMap, +} from "@/lib/map-calibration/control-map-index"; +import { + loadCalibration, + type LoadedCalibration, +} from "@/lib/map-calibration/load-calibration"; +import prisma from "@/lib/prisma"; +import { $Enums } from "@prisma/client"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "../errors"; +import { + mapCacheMissTotal, + mapCacheRequestTotal, + replayDataQueryDuration, + replayDataQueryErrorTotal, + replayDataQuerySuccessTotal, +} from "../metrics"; +import type { + DisplayEvent, + PositionSample, + ReplayCalibration, + ReplayData, +} from "./types"; + +function pushSample( + samples: PositionSample[], + t: number, + playerName: string, + playerTeam: string, + hero: string, + x: number | null, + z: number | null +) { + if (x != null && z != null) { + samples.push({ t, playerName, playerTeam, hero, x, z }); + } +} + +async function loadReplayCalibration( + mapName: string, + mapType: $Enums.MapType, + _mapDataId: number, + roundStarts: { match_time: number; objective_index: number }[] +): Promise { + const calibrations: Record = {}; + + if (mapType === $Enums.MapType.Control && isControlMap(mapName)) { + const allSubMapNames = getControlSubMapNames(mapName); + for (const subMapName of allSubMapNames) { + const cal = await loadCalibration(subMapName); + if (cal) calibrations[subMapName] = cal; + } + + if (Object.keys(calibrations).length === 0) return null; + + return { + calibrations, + mapName, + mapType, + roundStarts: roundStarts.map((r) => ({ + matchTime: r.match_time, + objectiveIndex: r.objective_index, + })), + }; + } + + const cal = await loadCalibration(mapName); + if (!cal) return null; + + calibrations[mapName] = cal; + + return { + calibrations, + mapName, + mapType, + roundStarts: [], + }; +} + +export type ReplayServiceInterface = { + readonly getReplayData: ( + mapDataId: number + ) => Effect.Effect; +}; + +export class ReplayService extends Context.Tag("@app/data/map/ReplayService")< + ReplayService, + ReplayServiceInterface +>() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getReplayData( + mapDataId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapDataId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapDataId", mapDataId); + const matchStart = yield* Effect.tryPromise({ + try: () => + prisma.matchStart.findFirst({ + where: { MapDataId: mapDataId }, + select: { + map_name: true, + map_type: true, + team_1_name: true, + team_2_name: true, + }, + }), + catch: (error) => + new MapQueryError({ + operation: "fetch match start for replay", + cause: error, + }), + }); + + if (!matchStart) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_calibration"; + yield* Metric.increment(replayDataQuerySuccessTotal); + return { type: "no_calibration" } as const; + } + + wideEvent.map_name = matchStart.map_name; + wideEvent.map_type = matchStart.map_type; + + const [ + kills, + damage, + healing, + ability1, + ability2, + ultStarts, + ultEnds, + ultCharged, + heroSwaps, + roundStarts, + roundEnds, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + attacker_name: true, + attacker_team: true, + attacker_hero: true, + attacker_x: true, + attacker_z: true, + victim_name: true, + victim_team: true, + victim_hero: true, + victim_x: true, + victim_z: true, + event_ability: true, + event_damage: true, + }, + }), + prisma.damage.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + attacker_name: true, + attacker_team: true, + attacker_hero: true, + attacker_x: true, + attacker_z: true, + victim_name: true, + victim_team: true, + victim_hero: true, + victim_x: true, + victim_z: true, + }, + }), + prisma.healing.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + healer_name: true, + healer_team: true, + healer_hero: true, + healer_x: true, + healer_z: true, + healee_name: true, + healee_team: true, + healee_hero: true, + healee_x: true, + healee_z: true, + }, + }), + prisma.ability1Used.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + }, + }), + prisma.ultimateStart.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + ultimate_id: true, + }, + }), + prisma.ultimateEnd.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + ultimate_id: true, + }, + }), + prisma.ultimateCharged.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + ultimate_id: true, + }, + }), + prisma.heroSwap.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + previous_hero: true, + }, + }), + prisma.roundStart.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + round_number: true, + objective_index: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: mapDataId }, + select: { + match_time: true, + round_number: true, + objective_index: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch replay event tables", + cause: error, + }), + }).pipe( + Effect.withSpan("map.replay.fetchAllEventTables", { + attributes: { mapDataId }, + }) + ); + + const hasCoords = + kills.some((e) => e.attacker_x != null || e.victim_x != null) || + damage.some((e) => e.attacker_x != null || e.victim_x != null) || + healing.some((e) => e.healer_x != null || e.healee_x != null) || + ability1.some((e) => e.player_x != null) || + ability2.some((e) => e.player_x != null) || + ultStarts.some((e) => e.player_x != null) || + ultEnds.some((e) => e.player_x != null); + + if (!hasCoords) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_coordinates"; + yield* Metric.increment(replayDataQuerySuccessTotal); + return { type: "no_coordinates" } as const; + } + + // Build position samples + const positionSamples: PositionSample[] = []; + + for (const k of kills) { + pushSample( + positionSamples, + k.match_time, + k.attacker_name, + k.attacker_team, + k.attacker_hero, + k.attacker_x, + k.attacker_z + ); + pushSample( + positionSamples, + k.match_time, + k.victim_name, + k.victim_team, + k.victim_hero, + k.victim_x, + k.victim_z + ); + } + + for (const d of damage) { + pushSample( + positionSamples, + d.match_time, + d.attacker_name, + d.attacker_team, + d.attacker_hero, + d.attacker_x, + d.attacker_z + ); + pushSample( + positionSamples, + d.match_time, + d.victim_name, + d.victim_team, + d.victim_hero, + d.victim_x, + d.victim_z + ); + } + + for (const h of healing) { + pushSample( + positionSamples, + h.match_time, + h.healer_name, + h.healer_team, + h.healer_hero, + h.healer_x, + h.healer_z + ); + pushSample( + positionSamples, + h.match_time, + h.healee_name, + h.healee_team, + h.healee_hero, + h.healee_x, + h.healee_z + ); + } + + for (const a of ability1) { + pushSample( + positionSamples, + a.match_time, + a.player_name, + a.player_team, + a.player_hero, + a.player_x, + a.player_z + ); + } + + for (const a of ability2) { + pushSample( + positionSamples, + a.match_time, + a.player_name, + a.player_team, + a.player_hero, + a.player_x, + a.player_z + ); + } + + for (const u of ultStarts) { + pushSample( + positionSamples, + u.match_time, + u.player_name, + u.player_team, + u.player_hero, + u.player_x, + u.player_z + ); + } + + for (const u of ultEnds) { + pushSample( + positionSamples, + u.match_time, + u.player_name, + u.player_team, + u.player_hero, + u.player_x, + u.player_z + ); + } + + positionSamples.sort((a, b) => a.t - b.t); + + const displayEvents: DisplayEvent[] = []; + + for (const k of kills) { + displayEvents.push({ + type: "kill", + t: k.match_time, + attackerName: k.attacker_name, + attackerTeam: k.attacker_team, + attackerHero: k.attacker_hero, + victimName: k.victim_name, + victimTeam: k.victim_team, + victimHero: k.victim_hero, + ability: k.event_ability, + damage: k.event_damage, + }); + } + + for (const u of ultStarts) { + displayEvents.push({ + type: "ult_start", + t: u.match_time, + playerName: u.player_name, + playerTeam: u.player_team, + playerHero: u.player_hero, + ultimateId: u.ultimate_id, + }); + } + + for (const u of ultEnds) { + displayEvents.push({ + type: "ult_end", + t: u.match_time, + playerName: u.player_name, + playerTeam: u.player_team, + playerHero: u.player_hero, + ultimateId: u.ultimate_id, + }); + } + + for (const u of ultCharged) { + displayEvents.push({ + type: "ult_charged", + t: u.match_time, + playerName: u.player_name, + playerTeam: u.player_team, + playerHero: u.player_hero, + ultimateId: u.ultimate_id, + }); + } + + for (const h of heroSwaps) { + displayEvents.push({ + type: "hero_swap", + t: h.match_time, + playerName: h.player_name, + playerTeam: h.player_team, + playerHero: h.player_hero, + previousHero: h.previous_hero, + }); + } + + for (const r of roundStarts) { + displayEvents.push({ + type: "round_start", + t: r.match_time, + roundNumber: r.round_number, + objectiveIndex: r.objective_index, + }); + } + + for (const r of roundEnds) { + displayEvents.push({ + type: "round_end", + t: r.match_time, + roundNumber: r.round_number, + objectiveIndex: r.objective_index, + }); + } + + displayEvents.sort((a, b) => a.t - b.t); + + const calibration = yield* Effect.tryPromise({ + try: () => + loadReplayCalibration( + matchStart.map_name, + matchStart.map_type, + mapDataId, + roundStarts + ), + catch: (error) => + new MapQueryError({ + operation: "load replay calibration", + cause: error, + }), + }); + + if (!calibration) { + wideEvent.outcome = "success"; + wideEvent.result_type = "no_calibration"; + yield* Metric.increment(replayDataQuerySuccessTotal); + return { type: "no_calibration" } as const; + } + + wideEvent.position_sample_count = positionSamples.length; + wideEvent.display_event_count = displayEvents.length; + wideEvent.outcome = "success"; + wideEvent.result_type = "ready"; + yield* Metric.increment(replayDataQuerySuccessTotal); + + return { + type: "ready", + positionSamples, + displayEvents, + calibration, + team1Name: matchStart.team_1_name, + team2Name: matchStart.team_2_name, + } as const; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(replayDataQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.replay.getReplayData") + : Effect.logInfo("map.replay.getReplayData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + replayDataQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.replay.getReplayData") + ); + } + + const replayCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapDataId: number) => + getReplayData(mapDataId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getReplayData: (mapDataId: number) => + replayCache + .get(mapDataId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies ReplayServiceInterface; + } +); + +export const ReplayServiceLive = Layer.effect(ReplayService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/map/replay/types.ts b/src/data/map/replay/types.ts new file mode 100644 index 000000000..4a51fe321 --- /dev/null +++ b/src/data/map/replay/types.ts @@ -0,0 +1,73 @@ +import type { LoadedCalibration } from "@/lib/map-calibration/load-calibration"; + +export type PositionSample = { + t: number; + playerName: string; + playerTeam: string; + hero: string; + x: number; + z: number; +}; + +export type KillDisplayEvent = { + type: "kill"; + t: number; + attackerName: string; + attackerTeam: string; + attackerHero: string; + victimName: string; + victimTeam: string; + victimHero: string; + ability: string; + damage: number; +}; + +export type UltDisplayEvent = { + type: "ult_start" | "ult_end" | "ult_charged"; + t: number; + playerName: string; + playerTeam: string; + playerHero: string; + ultimateId: number; +}; + +export type HeroSwapDisplayEvent = { + type: "hero_swap"; + t: number; + playerName: string; + playerTeam: string; + playerHero: string; + previousHero: string; +}; + +export type RoundDisplayEvent = { + type: "round_start" | "round_end"; + t: number; + roundNumber: number; + objectiveIndex: number; +}; + +export type DisplayEvent = + | KillDisplayEvent + | UltDisplayEvent + | HeroSwapDisplayEvent + | RoundDisplayEvent; + +export type ReplayCalibration = { + calibrations: Record; + mapName: string; + mapType: string; + roundStarts: { matchTime: number; objectiveIndex: number }[]; +}; + +export type ReplayData = + | { + type: "ready"; + positionSamples: PositionSample[]; + displayEvents: DisplayEvent[]; + calibration: ReplayCalibration; + team1Name: string; + team2Name: string; + } + | { type: "no_calibration" } + | { type: "no_coordinates" }; diff --git a/src/data/map/rotation-death-service.ts b/src/data/map/rotation-death-service.ts new file mode 100644 index 000000000..2f2c76047 --- /dev/null +++ b/src/data/map/rotation-death-service.ts @@ -0,0 +1,407 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { + detectRotationDeaths, + summarizeByPlayer, + type DamageEvent, + type NearbyPlayer, + type RotationDeathAnalysis, +} from "@/lib/replay/rotation-death-detection"; +import { groupEventsIntoFights, mercyRezToKillEvent } from "@/lib/utils"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "./errors"; +import { + mapCacheMissTotal, + mapCacheRequestTotal, + rotationDeathQueryDuration, + rotationDeathQueryErrorTotal, + rotationDeathQuerySuccessTotal, +} from "./metrics"; + +type PositionEvent = { + match_time: number; + playerName: string; + playerTeam: string; + hero: string; + x: number; + z: number; +}; + +const NEARBY_WINDOW_SEC = 10; + +function findNearbyPlayers( + killTime: number, + attackerName: string, + victimName: string, + positionEvents: PositionEvent[] +): NearbyPlayer[] { + const windowStart = killTime - NEARBY_WINDOW_SEC; + const closest = new Map(); + + let lo = 0; + let hi = positionEvents.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (positionEvents[mid].match_time < windowStart) { + lo = mid + 1; + } else { + hi = mid; + } + } + + for (let i = lo; i < positionEvents.length; i++) { + const e = positionEvents[i]; + if (e.match_time > killTime) break; + if (e.playerName === attackerName || e.playerName === victimName) continue; + + const key = `${e.playerName}::${e.playerTeam}`; + const dt = killTime - e.match_time; + const existing = closest.get(key); + if (!existing || dt < existing.dt) { + closest.set(key, { + player: { + playerName: e.playerName, + playerTeam: e.playerTeam, + hero: e.hero, + x: e.x, + z: e.z, + }, + dt, + }); + } + } + + return Array.from(closest.values()).map((v) => v.player); +} + +function pushPos( + arr: PositionEvent[], + matchTime: number, + name: string, + team: string, + hero: string, + x: number | null, + z: number | null +) { + if (x != null && z != null) { + arr.push({ + match_time: matchTime, + playerName: name, + playerTeam: team, + hero, + x, + z, + }); + } +} + +export type RotationDeathServiceInterface = { + readonly getRotationDeathAnalysis: ( + mapDataId: number + ) => Effect.Effect; +}; + +export class RotationDeathService extends Context.Tag( + "@app/data/map/RotationDeathService" +)() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getRotationDeathAnalysis( + mapDataId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapDataId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapDataId", mapDataId); + const resolvedId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapDataId), + catch: (error) => + new MapQueryError({ + operation: "resolve map data id for rotation death", + cause: error, + }), + }); + + wideEvent.resolvedMapDataId = resolvedId; + + const [kills, mercyRezzes, damage] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ where: { MapDataId: resolvedId } }), + prisma.mercyRez.findMany({ where: { MapDataId: resolvedId } }), + prisma.damage.findMany({ + where: { MapDataId: resolvedId }, + select: { + match_time: true, + attacker_name: true, + attacker_team: true, + attacker_hero: true, + attacker_x: true, + attacker_z: true, + victim_name: true, + victim_team: true, + victim_hero: true, + victim_x: true, + victim_z: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch kills and damage for rotation death", + cause: error, + }), + }).pipe( + Effect.withSpan("map.rotationDeath.fetchKillsAndDamage", { + attributes: { mapDataId: resolvedId }, + }) + ); + + if (kills.length === 0) { + wideEvent.outcome = "success"; + wideEvent.result = "no_kills"; + yield* Metric.increment(rotationDeathQuerySuccessTotal); + return null; + } + + wideEvent.kill_count = kills.length; + wideEvent.damage_count = damage.length; + + const damageEvents: DamageEvent[] = damage; + + const fightEvents = [ + ...kills, + ...mercyRezzes.map(mercyRezToKillEvent), + ].sort((a, b) => a.match_time - b.match_time); + + const fights = groupEventsIntoFights(fightEvents); + const results = detectRotationDeaths(fights, damageEvents); + const rotationDeaths = results.filter((r) => r.isRotationDeath); + + wideEvent.fight_count = fights.length; + wideEvent.rotation_death_count = rotationDeaths.length; + + if (rotationDeaths.length > 0) { + const [healing, ability1, ability2] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.healing.findMany({ + where: { MapDataId: resolvedId }, + select: { + match_time: true, + healer_name: true, + healer_team: true, + healer_hero: true, + healer_x: true, + healer_z: true, + healee_name: true, + healee_team: true, + healee_hero: true, + healee_x: true, + healee_z: true, + }, + }), + prisma.ability1Used.findMany({ + where: { MapDataId: resolvedId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: resolvedId }, + select: { + match_time: true, + player_name: true, + player_team: true, + player_hero: true, + player_x: true, + player_z: true, + }, + }), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch position data for rotation death", + cause: error, + }), + }).pipe( + Effect.withSpan("map.rotationDeath.fetchPositionData", { + attributes: { + mapDataId: resolvedId, + rotationDeathCount: rotationDeaths.length, + }, + }) + ); + + const positionEvents: PositionEvent[] = []; + + for (const d of damage) { + pushPos( + positionEvents, + d.match_time, + d.attacker_name, + d.attacker_team, + d.attacker_hero, + d.attacker_x, + d.attacker_z + ); + pushPos( + positionEvents, + d.match_time, + d.victim_name, + d.victim_team, + d.victim_hero, + d.victim_x, + d.victim_z + ); + } + for (const k of kills) { + pushPos( + positionEvents, + k.match_time, + k.attacker_name, + k.attacker_team, + k.attacker_hero, + k.attacker_x, + k.attacker_z + ); + pushPos( + positionEvents, + k.match_time, + k.victim_name, + k.victim_team, + k.victim_hero, + k.victim_x, + k.victim_z + ); + } + for (const h of healing) { + pushPos( + positionEvents, + h.match_time, + h.healer_name, + h.healer_team, + h.healer_hero, + h.healer_x, + h.healer_z + ); + pushPos( + positionEvents, + h.match_time, + h.healee_name, + h.healee_team, + h.healee_hero, + h.healee_x, + h.healee_z + ); + } + for (const a of ability1) { + pushPos( + positionEvents, + a.match_time, + a.player_name, + a.player_team, + a.player_hero, + a.player_x, + a.player_z + ); + } + for (const a of ability2) { + pushPos( + positionEvents, + a.match_time, + a.player_name, + a.player_team, + a.player_hero, + a.player_x, + a.player_z + ); + } + + positionEvents.sort((a, b) => a.match_time - b.match_time); + + for (const rd of rotationDeaths) { + rd.nearbyPlayers = findNearbyPlayers( + rd.kill.match_time, + rd.kill.attacker_name, + rd.kill.victim_name, + positionEvents + ); + } + } + + wideEvent.outcome = "success"; + yield* Metric.increment(rotationDeathQuerySuccessTotal); + + return { + mapDataId, + rotationDeaths, + totalKills: kills.length, + totalFights: fights.length, + playerSummaries: summarizeByPlayer(results), + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe( + Effect.andThen(Metric.increment(rotationDeathQueryErrorTotal)) + ) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.rotationDeath.getRotationDeathAnalysis") + : Effect.logInfo("map.rotationDeath.getRotationDeathAnalysis"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + rotationDeathQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("map.rotationDeath.getRotationDeathAnalysis") + ); + } + + const rotationDeathCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapDataId: number) => + getRotationDeathAnalysis(mapDataId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getRotationDeathAnalysis: (mapDataId: number) => + rotationDeathCache + .get(mapDataId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies RotationDeathServiceInterface; + } +); + +export const RotationDeathServiceLive = Layer.effect( + RotationDeathService, + make +).pipe(Layer.provide(EffectObservabilityLive)); diff --git a/src/data/map/tempo-service.ts b/src/data/map/tempo-service.ts new file mode 100644 index 000000000..9b60654e5 --- /dev/null +++ b/src/data/map/tempo-service.ts @@ -0,0 +1,389 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { + groupEventsIntoFights, + mercyRezToKillEvent, + type Fight, +} from "@/lib/utils"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "./errors"; +import { + mapCacheMissTotal, + mapCacheRequestTotal, + tempoQueryDuration, + tempoQueryErrorTotal, + tempoQuerySuccessTotal, +} from "./metrics"; + +export type TempoDataPoint = { + time: number; + team1: number; + team2: number; +}; + +export type UltPin = { + time: number; + hero: string; + playerName: string; + team: "team1" | "team2"; +}; + +export type FightBoundary = { + start: number; + end: number; + fightNumber: number; +}; + +export type KillPin = { + time: number; + hero: string; + playerName: string; + victimHero: string; + victimName: string; + team: "team1" | "team2"; +}; + +export type TempoChartData = { + combinedSeries: TempoDataPoint[]; + killsSeries: TempoDataPoint[]; + ultsSeries: TempoDataPoint[]; + ultPins: UltPin[]; + killPins: KillPin[]; + fightBoundaries: FightBoundary[]; + matchStartTime: number; + matchEndTime: number; + team1Name: string; + team2Name: string; +}; + +type WeightedEvent = { time: number; weight: number }; + +const SIGMA = 5; +const SAMPLE_INTERVAL = 0.5; +const TWO_SIGMA_SQ = 2 * SIGMA * SIGMA; + +function gaussianKDE( + events: WeightedEvent[], + sampleStart: number, + sampleEnd: number +): number[] { + const n = Math.ceil((sampleEnd - sampleStart) / SAMPLE_INTERVAL) + 1; + const values = new Array(n).fill(0); + + for (const e of events) { + const lo = Math.max( + 0, + Math.floor((e.time - 3 * SIGMA - sampleStart) / SAMPLE_INTERVAL) + ); + const hi = Math.min( + n - 1, + Math.ceil((e.time + 3 * SIGMA - sampleStart) / SAMPLE_INTERVAL) + ); + for (let i = lo; i <= hi; i++) { + const t = sampleStart + i * SAMPLE_INTERVAL; + const diff = t - e.time; + values[i] += e.weight * Math.exp(-(diff * diff) / TWO_SIGMA_SQ); + } + } + + return values; +} + +export function computeTempoSeries( + kills: { match_time: number; attacker_team: string }[], + ultStarts: { match_time: number; player_team: string }[], + team1Name: string, + matchStart: number, + matchEnd: number, + mode: "combined" | "kills" | "ults" +): TempoDataPoint[] { + const killWeight = 1.0; + const ultWeight = 0.6; + + const team1Events: WeightedEvent[] = []; + const team2Events: WeightedEvent[] = []; + + if (mode === "combined" || mode === "kills") { + for (const k of kills) { + const entry = { time: k.match_time, weight: killWeight }; + if (k.attacker_team === team1Name) { + team1Events.push(entry); + } else { + team2Events.push(entry); + } + } + } + + if (mode === "combined" || mode === "ults") { + for (const u of ultStarts) { + const entry = { time: u.match_time, weight: ultWeight }; + if (u.player_team === team1Name) { + team1Events.push(entry); + } else { + team2Events.push(entry); + } + } + } + + const t1Values = gaussianKDE(team1Events, matchStart, matchEnd); + const t2Values = gaussianKDE(team2Events, matchStart, matchEnd); + + let maxVal = 0; + for (let i = 0; i < t1Values.length; i++) { + maxVal = Math.max(maxVal, t1Values[i], t2Values[i]); + } + if (maxVal === 0) maxVal = 1; + + const series: TempoDataPoint[] = []; + for (let i = 0; i < t1Values.length; i++) { + series.push({ + time: matchStart + i * SAMPLE_INTERVAL, + team1: t1Values[i] / maxVal, + team2: t2Values[i] / maxVal, + }); + } + + return series; +} + +export function fightsToBoundaries(fights: Fight[]): FightBoundary[] { + return fights.map((f, i) => ({ + start: f.start, + end: f.end, + fightNumber: i + 1, + })); +} + +export function tempoPointsToSvgPath( + points: { x: number; y: number }[] +): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M${points[0].x},${points[0].y}`; + if (points.length === 2) { + return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`; + } + + const alpha = 0.5; + + let d = `M${points[0].x},${points[0].y}`; + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(points.length - 1, i + 2)]; + + const cp1x = p1.x + (p2.x - p0.x) / (6 / alpha); + const cp1y = p1.y + (p2.y - p0.y) / (6 / alpha); + const cp2x = p2.x - (p3.x - p1.x) / (6 / alpha); + const cp2y = p2.y - (p3.y - p1.y) / (6 / alpha); + + d += `C${cp1x},${cp1y},${cp2x},${cp2y},${p2.x},${p2.y}`; + } + + return d; +} + +export type TempoServiceInterface = { + readonly getTempoChartData: ( + mapId: number + ) => Effect.Effect; +}; + +export class TempoService extends Context.Tag("@app/data/map/TempoService")< + TempoService, + TempoServiceInterface +>() {} + +const CACHE_TTL = Duration.seconds(30); +const CACHE_CAPACITY = 64; + +export const make: Effect.Effect = Effect.gen( + function* () { + function getTempoChartData( + mapId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { mapId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("mapId", mapId); + const mapDataId = yield* Effect.tryPromise({ + try: () => resolveMapDataId(mapId), + catch: (error) => + new MapQueryError({ + operation: "resolve map data id for tempo", + cause: error, + }), + }); + + wideEvent.mapDataId = mapDataId; + + const [ + kills, + ultStarts, + matchStartRecord, + matchEndRecord, + mercyRezzes, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.kill.findMany({ where: { MapDataId: mapDataId } }), + prisma.ultimateStart.findMany({ + where: { MapDataId: mapDataId }, + }), + prisma.matchStart.findFirst({ + where: { MapDataId: mapDataId }, + }), + prisma.matchEnd.findFirst({ where: { MapDataId: mapDataId } }), + prisma.mercyRez.findMany({ where: { MapDataId: mapDataId } }), + ]), + catch: (error) => + new MapQueryError({ + operation: "fetch tempo chart data", + cause: error, + }), + }).pipe( + Effect.withSpan("map.tempo.fetchAllTables", { + attributes: { mapId, mapDataId }, + }) + ); + + if (!matchStartRecord || !matchEndRecord) { + wideEvent.outcome = "success"; + wideEvent.result = "no_match_records"; + yield* Metric.increment(tempoQuerySuccessTotal); + return null; + } + + if (kills.length < 3) { + wideEvent.outcome = "success"; + wideEvent.result = "insufficient_kills"; + wideEvent.kill_count = kills.length; + yield* Metric.increment(tempoQuerySuccessTotal); + return null; + } + + const team1Name = matchStartRecord.team_1_name; + const team2Name = matchStartRecord.team_2_name; + const matchStartTime = matchStartRecord.match_time; + const matchEndTime = matchEndRecord.match_time; + + wideEvent.team1 = team1Name; + wideEvent.team2 = team2Name; + wideEvent.kill_count = kills.length; + wideEvent.ult_start_count = ultStarts.length; + + const allKillEvents = [ + ...kills, + ...mercyRezzes.map(mercyRezToKillEvent), + ].sort((a, b) => a.match_time - b.match_time); + const fights = groupEventsIntoFights(allKillEvents); + const fightBoundaries = fightsToBoundaries(fights); + + const combinedSeries = computeTempoSeries( + kills, + ultStarts, + team1Name, + matchStartTime, + matchEndTime, + "combined" + ); + const killsSeries = computeTempoSeries( + kills, + ultStarts, + team1Name, + matchStartTime, + matchEndTime, + "kills" + ); + const ultsSeries = computeTempoSeries( + kills, + ultStarts, + team1Name, + matchStartTime, + matchEndTime, + "ults" + ); + + const ultPins: UltPin[] = ultStarts.map((u) => ({ + time: u.match_time, + hero: u.player_hero, + playerName: u.player_name, + team: u.player_team === team1Name ? "team1" : "team2", + })); + + const killPins: KillPin[] = kills.map((k) => ({ + time: k.match_time, + hero: k.attacker_hero, + playerName: k.attacker_name, + victimHero: k.victim_hero, + victimName: k.victim_name, + team: k.attacker_team === team1Name ? "team1" : "team2", + })); + + wideEvent.fight_count = fights.length; + wideEvent.outcome = "success"; + yield* Metric.increment(tempoQuerySuccessTotal); + + return { + combinedSeries, + killsSeries, + ultsSeries, + ultPins, + killPins, + fightBoundaries, + matchStartTime, + matchEndTime, + team1Name, + team2Name, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(tempoQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("map.tempo.getTempoChartData") + : Effect.logInfo("map.tempo.getTempoChartData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(tempoQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("map.tempo.getTempoChartData") + ); + } + + const tempoCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (mapId: number) => + getTempoChartData(mapId).pipe( + Effect.tap(() => Metric.increment(mapCacheMissTotal)) + ), + }); + + return { + getTempoChartData: (mapId: number) => + tempoCache + .get(mapId) + .pipe(Effect.tap(() => Metric.increment(mapCacheRequestTotal))), + } satisfies TempoServiceInterface; + } +); + +export const TempoServiceLive = Layer.effect(TempoService, make).pipe( + Layer.provide(EffectObservabilityLive) +); diff --git a/src/data/map/types.ts b/src/data/map/types.ts new file mode 100644 index 000000000..8783a4b10 --- /dev/null +++ b/src/data/map/types.ts @@ -0,0 +1,32 @@ +import { Schema as S } from "effect"; + +export const MapDataIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "MapData ID must be a positive integer" }) +); + +export const MapIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Map ID must be a positive integer" }) +); + +export const TeamIdSchema = S.Number.pipe( + S.int(), + S.positive({ message: () => "Team ID must be a positive integer" }) +); + +export const MapGroupCreateSchema = S.Struct({ + name: S.String, + description: S.optional(S.String), + teamId: S.Number, + mapIds: S.Array(S.Number), + category: S.optional(S.String), + createdBy: S.String, +}); + +export const MapGroupUpdateSchema = S.Struct({ + name: S.optional(S.String), + description: S.optional(S.String), + mapIds: S.optional(S.Array(S.Number)), + category: S.optional(S.String), +}); From 46614338febd27e620e014f7c2f5f183fae1f0a7 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:28 -0400 Subject: [PATCH 13/42] Add remaining team Effect services and barrel exports 13 domain services covering stats, trends, fight analysis, role stats, hero pool, hero swaps, map mode, quick wins, ult impact/stats, ban impact, ability impact, team comparison, and matchup/enemy hero analysis. Each consumes TeamSharedDataService via Effect Context. --- src/data/team/ability-impact-service.ts | 490 +++++++++++++ src/data/team/ban-impact-service.ts | 419 +++++++++++ src/data/team/comparison-service.ts | 700 ++++++++++++++++++ src/data/team/fight-stats-service.ts | 487 +++++++++++++ src/data/team/hero-pool-service.ts | 758 ++++++++++++++++++++ src/data/team/hero-swap-service.ts | 715 +++++++++++++++++++ src/data/team/index.ts | 161 +++++ src/data/team/map-mode-service.ts | 408 +++++++++++ src/data/team/matchup-service.ts | 485 +++++++++++++ src/data/team/quick-wins-service.ts | 472 ++++++++++++ src/data/team/role-stats-service.ts | 911 ++++++++++++++++++++++++ src/data/team/stats-service.ts | 852 ++++++++++++++++++++++ src/data/team/trends-service.ts | 749 +++++++++++++++++++ src/data/team/ult-service.ts | 835 ++++++++++++++++++++++ 14 files changed, 8442 insertions(+) create mode 100644 src/data/team/ability-impact-service.ts create mode 100644 src/data/team/ban-impact-service.ts create mode 100644 src/data/team/comparison-service.ts create mode 100644 src/data/team/fight-stats-service.ts create mode 100644 src/data/team/hero-pool-service.ts create mode 100644 src/data/team/hero-swap-service.ts create mode 100644 src/data/team/index.ts create mode 100644 src/data/team/map-mode-service.ts create mode 100644 src/data/team/matchup-service.ts create mode 100644 src/data/team/quick-wins-service.ts create mode 100644 src/data/team/role-stats-service.ts create mode 100644 src/data/team/stats-service.ts create mode 100644 src/data/team/trends-service.ts create mode 100644 src/data/team/ult-service.ts diff --git a/src/data/team/ability-impact-service.ts b/src/data/team/ability-impact-service.ts new file mode 100644 index 000000000..29a60e44e --- /dev/null +++ b/src/data/team/ability-impact-service.ts @@ -0,0 +1,490 @@ +import prisma from "@/lib/prisma"; +import { heroAbilityMapping } from "@/types/heroes"; +import type { HeroName } from "@/types/heroes"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { findTeamNameForMapInMemory } from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const abilityImpactQuerySuccessTotal = Metric.counter( + "team.ability_impact.query.success", + { + description: "Total successful team ability impact queries", + incremental: true, + } +); +const abilityImpactQueryErrorTotal = Metric.counter( + "team.ability_impact.query.error", + { description: "Total team ability impact query failures", incremental: true } +); +const abilityImpactQueryDuration = Metric.histogram( + "team.ability_impact.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team ability impact query duration in milliseconds" +); + +export type AbilityScenarioStats = { + fights: number; + wins: number; + losses: number; + winrate: number; +}; +export type AbilityImpactData = { + abilityName: string; + totalFightsAnalyzed: number; + scenarios: { + usedByUs: AbilityScenarioStats; + notUsedByUs: AbilityScenarioStats; + usedByEnemy: AbilityScenarioStats; + notUsedByEnemy: AbilityScenarioStats; + }; +}; +export type HeroAbilityImpact = { + hero: string; + ability1: AbilityImpactData; + ability2: AbilityImpactData; +}; +export type AbilityImpactAnalysis = { + byHero: Record; + availableHeroes: string[]; +}; + +function emptyScenario(): AbilityScenarioStats { + return { fights: 0, wins: 0, losses: 0, winrate: 0 }; +} +function finalizeScenario(s: AbilityScenarioStats): AbilityScenarioStats { + return { ...s, winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0 }; +} + +type FightEvent = { + event_type: string; + match_time: number; + attacker_team: string; + attacker_hero: string; + victim_team?: string; + victim_hero?: string; +}; +type Fight = { events: FightEvent[]; start: number; end: number }; +type AbilityEvent = { + match_time: number; + player_team: string; + player_hero: string; + MapDataId: number | null; +}; + +const ABILITY_PRE_BUFFER = 5; +const ABILITY_POST_BUFFER = 2; + +function processAbilityImpactAnalysis( + sharedData: ExtendedTeamData, + allAbility1Events: AbilityEvent[], + allAbility2Events: AbilityEvent[] +): AbilityImpactAnalysis { + const { teamRosterSet, mapDataIds, allPlayerStats, allKills, allRezzes } = + sharedData; + if (mapDataIds.length === 0) return { byHero: {}, availableHeroes: [] }; + + const teamNameByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (teamName) teamNameByMapId.set(mapDataId, teamName); + } + + const killsByMap = new Map(); + const rezzesByMap = new Map(); + const ab1ByMap = new Map(); + const ab2ByMap = new Map(); + + for (const kill of allKills) { + if (kill.MapDataId) { + if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); + killsByMap.get(kill.MapDataId)!.push(kill); + } + } + for (const rez of allRezzes) { + if (rez.MapDataId) { + if (!rezzesByMap.has(rez.MapDataId)) rezzesByMap.set(rez.MapDataId, []); + rezzesByMap.get(rez.MapDataId)!.push(rez); + } + } + for (const ab of allAbility1Events) { + if (ab.MapDataId) { + if (!ab1ByMap.has(ab.MapDataId)) ab1ByMap.set(ab.MapDataId, []); + ab1ByMap.get(ab.MapDataId)!.push(ab); + } + } + for (const ab of allAbility2Events) { + if (ab.MapDataId) { + if (!ab2ByMap.has(ab.MapDataId)) ab2ByMap.set(ab.MapDataId, []); + ab2ByMap.get(ab.MapDataId)!.push(ab); + } + } + + type AbilitySlotAccum = { + usedByUs: AbilityScenarioStats; + notUsedByUs: AbilityScenarioStats; + usedByEnemy: AbilityScenarioStats; + notUsedByEnemy: AbilityScenarioStats; + totalFightsAnalyzed: number; + }; + type HeroAccum = { ability1: AbilitySlotAccum; ability2: AbilitySlotAccum }; + const heroStats = new Map(); + + function getOrCreateHero(hero: string): HeroAccum { + if (!heroStats.has(hero)) + heroStats.set(hero, { + ability1: { + usedByUs: emptyScenario(), + notUsedByUs: emptyScenario(), + usedByEnemy: emptyScenario(), + notUsedByEnemy: emptyScenario(), + totalFightsAnalyzed: 0, + }, + ability2: { + usedByUs: emptyScenario(), + notUsedByUs: emptyScenario(), + usedByEnemy: emptyScenario(), + notUsedByEnemy: emptyScenario(), + totalFightsAnalyzed: 0, + }, + }); + return heroStats.get(hero)!; + } + + function incrementScenario(scenario: AbilityScenarioStats, won: boolean) { + scenario.fights++; + if (won) scenario.wins++; + else scenario.losses++; + } + + for (const mapDataId of mapDataIds) { + const ourTeamName = teamNameByMapId.get(mapDataId); + if (!ourTeamName) continue; + + const kills = killsByMap.get(mapDataId) ?? []; + const rezzes = rezzesByMap.get(mapDataId) ?? []; + const ab1s = ab1ByMap.get(mapDataId) ?? []; + const ab2s = ab2ByMap.get(mapDataId) ?? []; + if (kills.length === 0 && rezzes.length === 0) continue; + + const fightEvents: FightEvent[] = [ + ...kills.map((k) => ({ + event_type: k.event_type, + match_time: k.match_time, + attacker_team: k.attacker_team, + attacker_hero: k.attacker_hero, + victim_team: k.victim_team, + victim_hero: k.victim_hero, + })), + ...rezzes.map((rez) => ({ + event_type: "mercy_rez" as const, + match_time: rez.match_time, + attacker_team: rez.resurrecter_team, + attacker_hero: rez.resurrecter_hero, + victim_team: rez.resurrectee_team, + victim_hero: undefined, + })), + ]; + fightEvents.sort((a, b) => a.match_time - b.match_time); + + const fights: Fight[] = []; + let currentFight: Fight | null = null; + for (const event of fightEvents) { + if (!currentFight || event.match_time - currentFight.end > 15) { + currentFight = { + events: [event], + start: event.match_time, + end: event.match_time, + }; + fights.push(currentFight); + } else { + currentFight.events.push(event); + currentFight.end = event.match_time; + } + } + + for (const fight of fights) { + let ourKills = 0, + enemyKills = 0; + for (const event of fight.events) { + if (event.event_type === "mercy_rez") { + if (event.victim_team === ourTeamName) + enemyKills = Math.max(0, enemyKills - 1); + else ourKills = Math.max(0, ourKills - 1); + } else if (event.event_type === "kill") { + if (event.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + } + const won = ourKills > enemyKills; + + const heroPresence = new Map< + string, + { ourTeam: boolean; enemyTeam: boolean } + >(); + function markPresence(hero: string, team: string) { + if (!heroPresence.has(hero)) + heroPresence.set(hero, { ourTeam: false, enemyTeam: false }); + const p = heroPresence.get(hero)!; + if (team === ourTeamName) p.ourTeam = true; + else p.enemyTeam = true; + } + for (const event of fight.events) { + if (event.event_type === "kill" || event.event_type === "mercy_rez") { + markPresence(event.attacker_hero, event.attacker_team); + if (event.victim_hero && event.victim_team) + markPresence(event.victim_hero, event.victim_team); + } + } + + const windowStart = fight.start - ABILITY_PRE_BUFFER; + const windowEnd = fight.end + ABILITY_POST_BUFFER; + const fightAb1s = ab1s.filter( + (a) => a.match_time >= windowStart && a.match_time <= windowEnd + ); + const fightAb2s = ab2s.filter( + (a) => a.match_time >= windowStart && a.match_time <= windowEnd + ); + for (const ab of [...fightAb1s, ...fightAb2s]) + markPresence(ab.player_hero, ab.player_team); + + const ourAb1Heroes = new Set(), + ourAb2Heroes = new Set(), + enemyAb1Heroes = new Set(), + enemyAb2Heroes = new Set(); + for (const ab of fightAb1s) { + if (ab.player_team === ourTeamName) ourAb1Heroes.add(ab.player_hero); + else enemyAb1Heroes.add(ab.player_hero); + } + for (const ab of fightAb2s) { + if (ab.player_team === ourTeamName) ourAb2Heroes.add(ab.player_hero); + else enemyAb2Heroes.add(ab.player_hero); + } + + for (const [hero, presence] of heroPresence) { + const stats = getOrCreateHero(hero); + if (presence.ourTeam) { + stats.ability1.totalFightsAnalyzed++; + if (ourAb1Heroes.has(hero)) + incrementScenario(stats.ability1.usedByUs, won); + else incrementScenario(stats.ability1.notUsedByUs, won); + stats.ability2.totalFightsAnalyzed++; + if (ourAb2Heroes.has(hero)) + incrementScenario(stats.ability2.usedByUs, won); + else incrementScenario(stats.ability2.notUsedByUs, won); + } + if (presence.enemyTeam) { + if (enemyAb1Heroes.has(hero)) + incrementScenario(stats.ability1.usedByEnemy, won); + else incrementScenario(stats.ability1.notUsedByEnemy, won); + if (enemyAb2Heroes.has(hero)) + incrementScenario(stats.ability2.usedByEnemy, won); + else incrementScenario(stats.ability2.notUsedByEnemy, won); + } + } + } + } + + const byHero: Record = {}; + for (const [hero, stats] of heroStats) { + const totalFights = Math.max( + stats.ability1.totalFightsAnalyzed, + stats.ability2.totalFightsAnalyzed + ); + if (totalFights < 1) continue; + const abilities = heroAbilityMapping[hero as HeroName]; + if (!abilities) continue; + byHero[hero] = { + hero, + ability1: { + abilityName: abilities.ability1Name, + totalFightsAnalyzed: stats.ability1.totalFightsAnalyzed, + scenarios: { + usedByUs: finalizeScenario(stats.ability1.usedByUs), + notUsedByUs: finalizeScenario(stats.ability1.notUsedByUs), + usedByEnemy: finalizeScenario(stats.ability1.usedByEnemy), + notUsedByEnemy: finalizeScenario(stats.ability1.notUsedByEnemy), + }, + }, + ability2: { + abilityName: abilities.ability2Name, + totalFightsAnalyzed: stats.ability2.totalFightsAnalyzed, + scenarios: { + usedByUs: finalizeScenario(stats.ability2.usedByUs), + notUsedByUs: finalizeScenario(stats.ability2.notUsedByUs), + usedByEnemy: finalizeScenario(stats.ability2.usedByEnemy), + notUsedByEnemy: finalizeScenario(stats.ability2.notUsedByEnemy), + }, + }, + }; + } + + const availableHeroes = Object.values(byHero) + .sort( + (a, b) => + Math.max( + b.ability1.totalFightsAnalyzed, + b.ability2.totalFightsAnalyzed + ) - + Math.max(a.ability1.totalFightsAnalyzed, a.ability2.totalFightsAnalyzed) + ) + .map((h) => h.hero); + return { byHero, availableHeroes }; +} + +export type TeamAbilityImpactServiceInterface = { + readonly getTeamAbilityImpact: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamAbilityImpactService extends Context.Tag( + "@app/data/team/TeamAbilityImpactService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamAbilityImpact( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getExtendedTeamData(teamId, { dateRange }); + + if (data.mapDataIds.length === 0) { + wideEvent.outcome = "success"; + wideEvent.hero_count = 0; + yield* Metric.increment(abilityImpactQuerySuccessTotal); + const _empty: AbilityImpactAnalysis = { byHero: {}, availableHeroes: [] }; return _empty; + } + + const [allAbility1Events, allAbility2Events] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.ability1Used.findMany({ + where: { MapDataId: { in: data.mapDataIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.ability2Used.findMany({ + where: { MapDataId: { in: data.mapDataIds } }, + select: { + match_time: true, + player_team: true, + player_hero: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch ability events", + cause: error, + }), + }); + + const result = processAbilityImpactAnalysis( + data, + allAbility1Events, + allAbility2Events + ); + wideEvent.outcome = "success"; + wideEvent.hero_count = result.availableHeroes.length; + yield* Metric.increment(abilityImpactQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(abilityImpactQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.abilityImpact.getTeamAbilityImpact") + : Effect.logInfo("team.abilityImpact.getTeamAbilityImpact"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + abilityImpactQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("team.abilityImpact.getTeamAbilityImpact") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const abilityImpactCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamAbilityImpact(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamAbilityImpact: (teamId: number, dateRange?: TeamDateRange) => + abilityImpactCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamAbilityImpactServiceInterface; +}); + +export const TeamAbilityImpactServiceLive = Layer.effect( + TeamAbilityImpactService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/ban-impact-service.ts b/src/data/team/ban-impact-service.ts new file mode 100644 index 000000000..55a99a6e0 --- /dev/null +++ b/src/data/team/ban-impact-service.ts @@ -0,0 +1,419 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const banImpactQuerySuccessTotal = Metric.counter( + "team.ban_impact.query.success", + { description: "Total successful team ban impact queries", incremental: true } +); +const banImpactQueryErrorTotal = Metric.counter("team.ban_impact.query.error", { + description: "Total team ban impact query failures", + incremental: true, +}); +const banImpactQueryDuration = Metric.histogram( + "team.ban_impact.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team ban impact query duration in milliseconds" +); + +export type HeroBanImpact = { + hero: string; + totalBans: number; + banRate: number; + winRateWithHero: number; + winRateWithoutHero: number; + winRateDelta: number; + mapsPlayed: number; + mapsBanned: number; +}; +export type TeamBanImpactAnalysis = { + banImpacts: HeroBanImpact[]; + mostBanned: HeroBanImpact[]; + weakPoints: HeroBanImpact[]; + totalMapsAnalyzed: number; +}; +export type OurBanImpact = { + hero: string; + totalBans: number; + banRate: number; + winRateWhenBanned: number; + winRateWhenNotBanned: number; + winRateDelta: number; + mapsPlayed: number; + mapsBanned: number; +}; +export type TeamOurBanAnalysis = { + ourBanImpacts: OurBanImpact[]; + mostBannedByUs: OurBanImpact[]; + strongBans: OurBanImpact[]; + totalMapsAnalyzed: number; +}; +export type CombinedBanAnalysis = { + received: TeamBanImpactAnalysis; + outgoing: TeamOurBanAnalysis; +}; + +const MIN_BANS_FOR_SIGNIFICANCE = 3; +const WEAK_POINT_DELTA_THRESHOLD = 0.15; +const STRONG_BAN_DELTA_THRESHOLD = 0.1; + +function createEmptyAnalysis(): CombinedBanAnalysis { + return { + received: { + banImpacts: [], + mostBanned: [], + weakPoints: [], + totalMapsAnalyzed: 0, + }, + outgoing: { + ourBanImpacts: [], + mostBannedByUs: [], + strongBans: [], + totalMapsAnalyzed: 0, + }, + }; +} + +export type TeamBanImpactServiceInterface = { + readonly getTeamBanImpactAnalysis: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamBanImpactService extends Context.Tag( + "@app/data/team/TeamBanImpactService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamBanImpactAnalysis( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const sharedData = yield* shared.getBaseTeamData(teamId, { dateRange }); + + const { + teamRosterSet, + mapDataRecords, + mapDataIds, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.total_maps = 0; + yield* Metric.increment(banImpactQuerySuccessTotal); + return createEmptyAnalysis(); + } + + const heroBans = yield* Effect.tryPromise({ + try: () => + prisma.heroBan.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { MapDataId: true, team: true, hero: true }, + }), + catch: (error) => + new TeamQueryError({ operation: "fetch hero bans", cause: error }), + }); + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type MapOutcome = { + mapDataId: number; + teamName: string; + isWin: boolean; + bannedHeroes: Set; + heroesBannedByUs: Set; + }; + const mapOutcomes: MapOutcome[] = []; + + const bansByMapId = new Map(); + for (const ban of heroBans) { + if (!ban.MapDataId) continue; + if (!bansByMapId.has(ban.MapDataId)) bansByMapId.set(ban.MapDataId, []); + bansByMapId + .get(ban.MapDataId)! + .push({ team: ban.team, hero: ban.hero }); + } + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => + stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) + continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + const mapBans = bansByMapId.get(mapDataId) ?? []; + const bannedHeroes = new Set(); + const heroesBannedByUs = new Set(); + for (const ban of mapBans) { + if (ban.team !== teamName) bannedHeroes.add(ban.hero); + else heroesBannedByUs.add(ban.hero); + } + mapOutcomes.push({ + mapDataId, + teamName, + isWin, + bannedHeroes, + heroesBannedByUs, + }); + } + + if (mapOutcomes.length === 0) { + wideEvent.outcome = "success"; + wideEvent.total_maps = 0; + yield* Metric.increment(banImpactQuerySuccessTotal); + return createEmptyAnalysis(); + } + + const totalMaps = mapOutcomes.length; + const overallWins = mapOutcomes.filter((o) => o.isWin).length; + const overallWinRate = totalMaps > 0 ? overallWins / totalMaps : 0; + + // Received bans + const allBannedHeroes = new Set(); + for (const outcome of mapOutcomes) + for (const hero of outcome.bannedHeroes) allBannedHeroes.add(hero); + + const banImpacts: HeroBanImpact[] = []; + for (const hero of allBannedHeroes) { + const mapsWithBan = mapOutcomes.filter((o) => o.bannedHeroes.has(hero)); + const mapsWithoutBan = mapOutcomes.filter( + (o) => !o.bannedHeroes.has(hero) + ); + const mapsBanned = mapsWithBan.length; + if (mapsBanned < MIN_BANS_FOR_SIGNIFICANCE) continue; + const winsWhenBanned = mapsWithBan.filter((o) => o.isWin).length; + const winsWhenAvailable = mapsWithoutBan.filter((o) => o.isWin).length; + const winRateWithoutHero = + mapsBanned > 0 ? winsWhenBanned / mapsBanned : 0; + const winRateWithHero = + mapsWithoutBan.length > 0 + ? winsWhenAvailable / mapsWithoutBan.length + : overallWinRate; + const winRateDelta = winRateWithHero - winRateWithoutHero; + banImpacts.push({ + hero, + totalBans: mapsBanned, + banRate: mapsBanned / totalMaps, + winRateWithHero, + winRateWithoutHero, + winRateDelta, + mapsPlayed: totalMaps, + mapsBanned, + }); + } + banImpacts.sort((a, b) => b.banRate - a.banRate); + const mostBanned = banImpacts.slice(0, 10); + const weakPoints = banImpacts + .filter( + (impact) => + impact.winRateDelta >= WEAK_POINT_DELTA_THRESHOLD && + impact.mapsBanned >= MIN_BANS_FOR_SIGNIFICANCE + ) + .sort((a, b) => b.winRateDelta - a.winRateDelta); + + const received: TeamBanImpactAnalysis = { + banImpacts, + mostBanned, + weakPoints, + totalMapsAnalyzed: totalMaps, + }; + + // Outgoing bans + const allHeroesBannedByUs = new Set(); + for (const outcome of mapOutcomes) + for (const hero of outcome.heroesBannedByUs) + allHeroesBannedByUs.add(hero); + + const ourBanImpacts: OurBanImpact[] = []; + for (const hero of allHeroesBannedByUs) { + const mapsWhereBanned = mapOutcomes.filter((o) => + o.heroesBannedByUs.has(hero) + ); + const mapsWhereNotBanned = mapOutcomes.filter( + (o) => !o.heroesBannedByUs.has(hero) + ); + const mapsBanned = mapsWhereBanned.length; + if (mapsBanned < MIN_BANS_FOR_SIGNIFICANCE) continue; + const winsWhenWeBanned = mapsWhereBanned.filter((o) => o.isWin).length; + const winsWhenWeDidNotBan = mapsWhereNotBanned.filter( + (o) => o.isWin + ).length; + const winRateWhenBanned = + mapsBanned > 0 ? winsWhenWeBanned / mapsBanned : 0; + const winRateWhenNotBanned = + mapsWhereNotBanned.length > 0 + ? winsWhenWeDidNotBan / mapsWhereNotBanned.length + : overallWinRate; + const winRateDelta = winRateWhenBanned - winRateWhenNotBanned; + ourBanImpacts.push({ + hero, + totalBans: mapsBanned, + banRate: mapsBanned / totalMaps, + winRateWhenBanned, + winRateWhenNotBanned, + winRateDelta, + mapsPlayed: totalMaps, + mapsBanned, + }); + } + ourBanImpacts.sort((a, b) => b.banRate - a.banRate); + const mostBannedByUs = ourBanImpacts.slice(0, 10); + const strongBans = ourBanImpacts + .filter( + (impact) => + impact.winRateDelta >= STRONG_BAN_DELTA_THRESHOLD && + impact.mapsBanned >= MIN_BANS_FOR_SIGNIFICANCE + ) + .sort((a, b) => b.winRateDelta - a.winRateDelta); + + const outgoing: TeamOurBanAnalysis = { + ourBanImpacts, + mostBannedByUs, + strongBans, + totalMapsAnalyzed: totalMaps, + }; + + wideEvent.outcome = "success"; + wideEvent.total_maps = totalMaps; + wideEvent.received_ban_count = banImpacts.length; + wideEvent.outgoing_ban_count = ourBanImpacts.length; + yield* Metric.increment(banImpactQuerySuccessTotal); + + return { received, outgoing }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(banImpactQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.banImpact.getTeamBanImpactAnalysis") + : Effect.logInfo("team.banImpact.getTeamBanImpactAnalysis"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(banImpactQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.banImpact.getTeamBanImpactAnalysis") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const banImpactCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamBanImpactAnalysis(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamBanImpactAnalysis: (teamId: number, dateRange?: TeamDateRange) => + banImpactCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamBanImpactServiceInterface; +}); + +export const TeamBanImpactServiceLive = Layer.effect( + TeamBanImpactService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/comparison-service.ts b/src/data/team/comparison-service.ts new file mode 100644 index 000000000..5ace9bebb --- /dev/null +++ b/src/data/team/comparison-service.ts @@ -0,0 +1,700 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName } from "@/types/heroes"; +import type { + TeamComparisonStats, + TeamMapBreakdown, +} from "@/types/team-comparison"; +import type { CalculatedStat, MapType, PlayerStat } from "@prisma/client"; +import { CalculatedStatType } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; + +import type { AggregatedStats } from "@/data/comparison/types"; + +import { TeamQueryError } from "./errors"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { findTeamNameForMapInMemory } from "./shared-core"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const comparisonQuerySuccessTotal = Metric.counter( + "team.comparison.query.success", + { description: "Total successful team comparison queries", incremental: true } +); +const comparisonQueryErrorTotal = Metric.counter( + "team.comparison.query.error", + { description: "Total team comparison query failures", incremental: true } +); +const comparisonQueryDuration = Metric.histogram( + "team.comparison.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team comparison query duration in milliseconds" +); + +function calculatePer10(value: number, timePlayed: number): number { + if (timePlayed === 0) return 0; + return (value / timePlayed) * 600; +} + +function calculatePercentage(value: number, total: number): number { + if (total === 0) return 0; + return (value / total) * 100; +} + +function aggregateCalculatedStatsForTeam( + stats: CalculatedStat[] +): Partial { + const result: Partial = { + fletaDeadliftPercentage: 0, + firstPickPercentage: 0, + firstPickCount: 0, + firstDeathPercentage: 0, + firstDeathCount: 0, + mvpScore: 0, + mapMvpCount: 0, + ajaxCount: 0, + averageUltChargeTime: 0, + averageTimeToUseUlt: 0, + averageDroughtTime: 0, + killsPerUltimate: 0, + duelWinratePercentage: 0, + fightReversalPercentage: 0, + }; + const counts: Record = {}; + + stats.forEach((stat) => { + switch (stat.stat) { + case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) + stat.value; + counts.fletaDeadlift = (counts.fletaDeadlift ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_PERCENTAGE: + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) + stat.value; + counts.firstPick = (counts.firstPick ?? 0) + 1; + break; + case CalculatedStatType.FIRST_PICK_COUNT: + result.firstPickCount = (result.firstPickCount ?? 0) + stat.value; + break; + case CalculatedStatType.FIRST_DEATH_PERCENTAGE: + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) + stat.value; + counts.firstDeath = (counts.firstDeath ?? 0) + 1; + break; + case CalculatedStatType.FIRST_DEATH_COUNT: + result.firstDeathCount = (result.firstDeathCount ?? 0) + stat.value; + break; + case CalculatedStatType.MVP_SCORE: + result.mvpScore = (result.mvpScore ?? 0) + stat.value; + counts.mvpScore = (counts.mvpScore ?? 0) + 1; + break; + case CalculatedStatType.MAP_MVP_COUNT: + result.mapMvpCount = (result.mapMvpCount ?? 0) + stat.value; + break; + case CalculatedStatType.AJAX_COUNT: + result.ajaxCount = (result.ajaxCount ?? 0) + stat.value; + break; + case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) + stat.value; + counts.ultCharge = (counts.ultCharge ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_TIME_TO_USE_ULT: + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) + stat.value; + counts.timeToUseUlt = (counts.timeToUseUlt ?? 0) + 1; + break; + case CalculatedStatType.AVERAGE_DROUGHT_TIME: + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) + stat.value; + counts.drought = (counts.drought ?? 0) + 1; + break; + case CalculatedStatType.KILLS_PER_ULTIMATE: + result.killsPerUltimate = (result.killsPerUltimate ?? 0) + stat.value; + counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; + break; + case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) + stat.value; + counts.duelWinrate = (counts.duelWinrate ?? 0) + 1; + break; + case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) + stat.value; + counts.fightReversal = (counts.fightReversal ?? 0) + 1; + break; + } + }); + + if (counts.fletaDeadlift) + result.fletaDeadliftPercentage = + (result.fletaDeadliftPercentage ?? 0) / counts.fletaDeadlift; + if (counts.firstPick) + result.firstPickPercentage = + (result.firstPickPercentage ?? 0) / counts.firstPick; + if (counts.firstDeath) + result.firstDeathPercentage = + (result.firstDeathPercentage ?? 0) / counts.firstDeath; + if (counts.mvpScore) + result.mvpScore = (result.mvpScore ?? 0) / counts.mvpScore; + if (counts.ultCharge) + result.averageUltChargeTime = + (result.averageUltChargeTime ?? 0) / counts.ultCharge; + if (counts.timeToUseUlt) + result.averageTimeToUseUlt = + (result.averageTimeToUseUlt ?? 0) / counts.timeToUseUlt; + if (counts.drought) + result.averageDroughtTime = + (result.averageDroughtTime ?? 0) / counts.drought; + if (counts.killsPerUlt) + result.killsPerUltimate = + (result.killsPerUltimate ?? 0) / counts.killsPerUlt; + if (counts.duelWinrate) + result.duelWinratePercentage = + (result.duelWinratePercentage ?? 0) / counts.duelWinrate; + if (counts.fightReversal) + result.fightReversalPercentage = + (result.fightReversalPercentage ?? 0) / counts.fightReversal; + + return result; +} + +function aggregateTeamStats( + stats: PlayerStat[], + calculatedStats: CalculatedStat[] +): AggregatedStats { + const totals = stats.reduce( + (acc, stat) => { + acc.eliminations += stat.eliminations; + acc.finalBlows += stat.final_blows; + acc.deaths += stat.deaths; + acc.allDamageDealt += stat.all_damage_dealt; + acc.barrierDamageDealt += stat.barrier_damage_dealt; + acc.heroDamageDealt += stat.hero_damage_dealt; + acc.healingDealt += stat.healing_dealt; + acc.healingReceived += stat.healing_received; + acc.selfHealing += stat.self_healing; + acc.damageTaken += stat.damage_taken; + acc.damageBlocked += stat.damage_blocked; + acc.defensiveAssists += stat.defensive_assists; + acc.offensiveAssists += stat.offensive_assists; + acc.ultimatesEarned += stat.ultimates_earned; + acc.ultimatesUsed += stat.ultimates_used; + acc.multikillBest = Math.max(acc.multikillBest, stat.multikill_best); + acc.multikills += stat.multikills; + acc.soloKills += stat.solo_kills; + acc.objectiveKills += stat.objective_kills; + acc.environmentalKills += stat.environmental_kills; + acc.environmentalDeaths += stat.environmental_deaths; + acc.criticalHits += stat.critical_hits; + acc.shotsFired += stat.shots_fired; + acc.shotsHit += stat.shots_hit; + acc.shotsMissed += stat.shots_missed; + acc.scopedShots += stat.scoped_shots; + acc.scopedShotsHit += stat.scoped_shots_hit; + acc.scopedCriticalHitKills += stat.scoped_critical_hit_kills; + acc.heroTimePlayed += stat.hero_time_played; + return acc; + }, + { + eliminations: 0, + finalBlows: 0, + deaths: 0, + allDamageDealt: 0, + barrierDamageDealt: 0, + heroDamageDealt: 0, + healingDealt: 0, + healingReceived: 0, + selfHealing: 0, + damageTaken: 0, + damageBlocked: 0, + defensiveAssists: 0, + offensiveAssists: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + multikillBest: 0, + multikills: 0, + soloKills: 0, + objectiveKills: 0, + environmentalKills: 0, + environmentalDeaths: 0, + criticalHits: 0, + shotsFired: 0, + shotsHit: 0, + shotsMissed: 0, + scopedShots: 0, + scopedShotsHit: 0, + scopedCriticalHitKills: 0, + heroTimePlayed: 0, + } + ); + + const ca = aggregateCalculatedStatsForTeam(calculatedStats); + + return { + ...totals, + eliminationsPer10: calculatePer10( + totals.eliminations, + totals.heroTimePlayed + ), + finalBlowsPer10: calculatePer10(totals.finalBlows, totals.heroTimePlayed), + deathsPer10: calculatePer10(totals.deaths, totals.heroTimePlayed), + allDamagePer10: calculatePer10( + totals.allDamageDealt, + totals.heroTimePlayed + ), + heroDamagePer10: calculatePer10( + totals.heroDamageDealt, + totals.heroTimePlayed + ), + healingDealtPer10: calculatePer10( + totals.healingDealt, + totals.heroTimePlayed + ), + healingReceivedPer10: calculatePer10( + totals.healingReceived, + totals.heroTimePlayed + ), + damageTakenPer10: calculatePer10(totals.damageTaken, totals.heroTimePlayed), + damageBlockedPer10: calculatePer10( + totals.damageBlocked, + totals.heroTimePlayed + ), + ultimatesEarnedPer10: calculatePer10( + totals.ultimatesEarned, + totals.heroTimePlayed + ), + ultimatesUsedPer10: calculatePer10( + totals.ultimatesUsed, + totals.heroTimePlayed + ), + soloKillsPer10: calculatePer10(totals.soloKills, totals.heroTimePlayed), + objectiveKillsPer10: calculatePer10( + totals.objectiveKills, + totals.heroTimePlayed + ), + defensiveAssistsPer10: calculatePer10( + totals.defensiveAssists, + totals.heroTimePlayed + ), + offensiveAssistsPer10: calculatePer10( + totals.offensiveAssists, + totals.heroTimePlayed + ), + environmentalKillsPer10: calculatePer10( + totals.environmentalKills, + totals.heroTimePlayed + ), + environmentalDeathsPer10: calculatePer10( + totals.environmentalDeaths, + totals.heroTimePlayed + ), + multikillsPer10: calculatePer10(totals.multikills, totals.heroTimePlayed), + barrierDamagePer10: calculatePer10( + totals.barrierDamageDealt, + totals.heroTimePlayed + ), + selfHealingPer10: calculatePer10(totals.selfHealing, totals.heroTimePlayed), + firstPicksPer10: calculatePer10( + ca.firstPickCount ?? 0, + totals.heroTimePlayed + ), + firstDeathsPer10: calculatePer10( + ca.firstDeathCount ?? 0, + totals.heroTimePlayed + ), + mapMvpRate: + calculatedStats.length > 0 + ? ((ca.mapMvpCount ?? 0) / calculatedStats.length) * 100 + : 0, + ajaxPer10: calculatePer10(ca.ajaxCount ?? 0, totals.heroTimePlayed), + weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired), + criticalHitAccuracy: calculatePercentage( + totals.criticalHits, + totals.shotsHit + ), + scopedAccuracy: calculatePercentage( + totals.scopedShotsHit, + totals.scopedShots + ), + scopedCriticalHitAccuracy: calculatePercentage( + totals.scopedCriticalHitKills, + totals.scopedShotsHit + ), + fletaDeadliftPercentage: ca.fletaDeadliftPercentage ?? 0, + firstPickPercentage: ca.firstPickPercentage ?? 0, + firstPickCount: ca.firstPickCount ?? 0, + firstDeathPercentage: ca.firstDeathPercentage ?? 0, + firstDeathCount: ca.firstDeathCount ?? 0, + mvpScore: ca.mvpScore ?? 0, + mapMvpCount: ca.mapMvpCount ?? 0, + ajaxCount: ca.ajaxCount ?? 0, + averageUltChargeTime: ca.averageUltChargeTime ?? 0, + averageTimeToUseUlt: ca.averageTimeToUseUlt ?? 0, + averageDroughtTime: ca.averageDroughtTime ?? 0, + killsPerUltimate: ca.killsPerUltimate ?? 0, + duelWinratePercentage: ca.duelWinratePercentage ?? 0, + fightReversalPercentage: ca.fightReversalPercentage ?? 0, + eliminationsPer10StdDev: 0, + deathsPer10StdDev: 0, + allDamagePer10StdDev: 0, + healingDealtPer10StdDev: 0, + firstPickPercentageStdDev: 0, + consistencyScore: 0, + }; +} + +export type TeamComparisonServiceInterface = { + readonly getTeamComparisonStats: ( + mapIds: number[], + teamId: number, + heroes?: HeroName[] + ) => Effect.Effect; +}; + +export class TeamComparisonService extends Context.Tag( + "@app/data/team/TeamComparisonService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamComparisonStats( + mapIds: number[], + teamId: number, + heroes?: HeroName[] + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + mapCount: mapIds.length, + hasHeroFilter: !!(heroes && heroes.length > 0), + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("mapCount", mapIds.length); + if (mapIds.length === 0) { + return yield* new TeamQueryError({ + operation: "team comparison - no maps provided", + cause: null, + }); + } + + const teamRoster = yield* shared.getTeamRoster(teamId); + const teamRosterSet = new Set(teamRoster); + + if (teamRoster.length === 0) { + return yield* new TeamQueryError({ + operation: "team comparison - no roster found", + cause: null, + }); + } + + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { id: { in: mapIds } }, + include: { + Scrim: true, + mapData: { + include: { + match_start: true, + round_end: true, + objective_captured: true, + payload_progress: true, + point_progress: true, + }, + }, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch maps for comparison", + cause: error, + }), + }); + + const mapDataIds = maps.flatMap((map) => map.mapData.map((md) => md.id)); + + if (mapDataIds.length === 0) { + return yield* new TeamQueryError({ + operation: "team comparison - no map data found", + cause: null, + }); + } + + const [allPlayerStats, allCalculatedStats] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.playerStat.findMany({ + where: { + MapDataId: { in: mapDataIds }, + ...(heroes && heroes.length > 0 + ? { player_hero: { in: heroes } } + : {}), + }, + }), + prisma.calculatedStat.findMany({ + where: { + MapDataId: { in: mapDataIds }, + ...(heroes && heroes.length > 0 + ? { hero: { in: heroes } } + : {}), + }, + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch stats for comparison", + cause: error, + }), + }); + + const mapDataIdToMapId = new Map(); + for (const map of maps) + for (const mapData of map.mapData) + mapDataIdToMapId.set(mapData.id, map.id); + + const myTeamStats: PlayerStat[] = []; + const enemyTeamStats: PlayerStat[] = []; + const myTeamCalculatedStats: CalculatedStat[] = []; + const enemyTeamCalculatedStats: CalculatedStat[] = []; + const perMapBreakdown: TeamMapBreakdown[] = []; + + for (const map of maps) { + const mapMdIds = new Set(map.mapData.map((md) => md.id)); + const mapPlayerStats = allPlayerStats.filter( + (stat) => stat.MapDataId !== null && mapMdIds.has(stat.MapDataId) + ); + if (mapPlayerStats.length === 0) continue; + + const firstMdId = map.mapData[0]?.id ?? map.id; + const myTeamName = findTeamNameForMapInMemory( + firstMdId, + mapPlayerStats, + teamRosterSet + ); + if (!myTeamName) continue; + + const firstMapData = map.mapData[0]; + const matchStart = firstMapData?.match_start[0]; + const roundEnds = firstMapData?.round_end ?? []; + const finalRound = + roundEnds.length > 0 + ? roundEnds.reduce((latest, current) => + current.round_number > latest.round_number ? current : latest + ) + : null; + const allCaptures = firstMapData?.objective_captured ?? []; + const team1Captures = allCaptures.filter( + (c) => c.capturing_team === matchStart?.team_1_name + ); + const team2Captures = allCaptures.filter( + (c) => c.capturing_team === matchStart?.team_2_name + ); + const allPayloadProgress = firstMapData?.payload_progress ?? []; + const team1PayloadProgress = allPayloadProgress.filter( + (p) => p.capturing_team === matchStart?.team_1_name + ); + const team2PayloadProgress = allPayloadProgress.filter( + (p) => p.capturing_team === matchStart?.team_2_name + ); + const allPointProgress = firstMapData?.point_progress ?? []; + const team1PointProgress = allPointProgress.filter( + (p) => p.capturing_team === matchStart?.team_1_name + ); + const team2PointProgress = allPointProgress.filter( + (p) => p.capturing_team === matchStart?.team_2_name + ); + + const enemyTeamName = + matchStart?.team_1_name === myTeamName + ? matchStart?.team_2_name + : matchStart?.team_1_name; + + const myTeamMapStats = mapPlayerStats.filter( + (stat) => stat.player_team === myTeamName + ); + const enemyTeamMapStats = mapPlayerStats.filter( + (stat) => stat.player_team === enemyTeamName + ); + myTeamStats.push(...myTeamMapStats); + enemyTeamStats.push(...enemyTeamMapStats); + + const mapCalculatedStats = allCalculatedStats.filter((stat) => + mapMdIds.has(stat.MapDataId) + ); + const myTeamMapCalculatedStats = mapCalculatedStats.filter((stat) => + teamRosterSet.has(stat.playerName) + ); + const enemyTeamMapCalculatedStats = mapCalculatedStats.filter( + (stat) => !teamRosterSet.has(stat.playerName) + ); + myTeamCalculatedStats.push(...myTeamMapCalculatedStats); + enemyTeamCalculatedStats.push(...enemyTeamMapCalculatedStats); + + const myTeamMapAggregated = aggregateTeamStats( + myTeamMapStats, + myTeamMapCalculatedStats + ); + const enemyTeamMapAggregated = aggregateTeamStats( + enemyTeamMapStats, + enemyTeamMapCalculatedStats + ); + + const winnerTeamName = calculateWinner({ + matchDetails: matchStart ?? null, + finalRound: finalRound ?? null, + team1Captures, + team2Captures, + team1PayloadProgress, + team2PayloadProgress, + team1PointProgress, + team2PointProgress, + }); + + const winner: TeamMapBreakdown["winner"] = + winnerTeamName === myTeamName + ? "myTeam" + : winnerTeamName === enemyTeamName + ? "enemyTeam" + : winnerTeamName === "N/A" + ? null + : "draw"; + + perMapBreakdown.push({ + mapId: map.id, + mapDataId: firstMapData?.id ?? 0, + mapName: matchStart?.map_name || map.name, + mapType: matchStart?.map_type || ("Control" as MapType), + scrimId: map.scrimId ?? 0, + scrimName: map.Scrim?.name ?? "Unknown", + date: map.Scrim?.date ?? map.createdAt, + replayCode: map.replayCode, + myTeamName: myTeamName ?? "My Team", + enemyTeamName: enemyTeamName ?? "Enemy Team", + myTeamStats: myTeamMapAggregated, + enemyTeamStats: enemyTeamMapAggregated, + winner, + }); + } + + perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); + + const myTeamAggregated = aggregateTeamStats( + myTeamStats, + myTeamCalculatedStats + ); + const enemyTeamAggregated = aggregateTeamStats( + enemyTeamStats, + enemyTeamCalculatedStats + ); + + const firstMapTeamName = + perMapBreakdown.length > 0 ? perMapBreakdown[0].myTeamName : "My Team"; + const firstMapEnemyTeamName = + perMapBreakdown.length > 0 + ? perMapBreakdown[0].enemyTeamName + : "Enemy Team"; + + const myTeamPlayerSet = new Set( + myTeamStats.map((stat) => stat.player_name) + ); + const enemyTeamPlayerSet = new Set( + enemyTeamStats.map((stat) => stat.player_name) + ); + + wideEvent.outcome = "success"; + wideEvent.per_map_count = perMapBreakdown.length; + yield* Metric.increment(comparisonQuerySuccessTotal); + + return { + mapCount: perMapBreakdown.length, + mapIds, + myTeam: { + teamName: firstMapTeamName, + playerCount: myTeamPlayerSet.size, + stats: myTeamAggregated, + }, + enemyTeam: { + teamName: firstMapEnemyTeamName, + playerCount: enemyTeamPlayerSet.size, + stats: enemyTeamAggregated, + }, + perMapBreakdown, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(comparisonQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.comparison.getTeamComparisonStats") + : Effect.logInfo("team.comparison.getTeamComparisonStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(comparisonQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.comparison.getTeamComparisonStats") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + const comparisonCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const parsed = JSON.parse(key) as { + mapIds: number[]; + teamId: number; + heroes?: HeroName[]; + }; + return getTeamComparisonStats( + parsed.mapIds, + parsed.teamId, + parsed.heroes + ).pipe(Effect.tap(() => Metric.increment(teamCacheMissTotal))); + }, + }); + + return { + getTeamComparisonStats: ( + mapIds: number[], + teamId: number, + heroes?: HeroName[] + ) => + comparisonCache + .get(JSON.stringify({ mapIds, teamId, heroes })) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamComparisonServiceInterface; +}); + +export const TeamComparisonServiceLive = Layer.effect( + TeamComparisonService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/fight-stats-service.ts b/src/data/team/fight-stats-service.ts new file mode 100644 index 000000000..1f7edf535 --- /dev/null +++ b/src/data/team/fight-stats-service.ts @@ -0,0 +1,487 @@ +import { mercyRezToKillEvent, ultimateStartToKillEvent } from "@/lib/utils"; +import type { Kill } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import type { TeamQueryError } from "./errors"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { findTeamNameForMapInMemory } from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const fightStatsQuerySuccessTotal = Metric.counter( + "team.fight_stats.query.success", + { + description: "Total successful team fight stats queries", + incremental: true, + } +); + +const fightStatsQueryErrorTotal = Metric.counter( + "team.fight_stats.query.error", + { description: "Total team fight stats query failures", incremental: true } +); + +const fightStatsQueryDuration = Metric.histogram( + "team.fight_stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team fight stats query duration in milliseconds" +); + +export type TeamFightStats = { + totalFights: number; + fightsWon: number; + fightsLost: number; + overallWinrate: number; + firstPickFights: number; + firstPickWins: number; + firstPickWinrate: number; + firstDeathFights: number; + firstDeathWins: number; + firstDeathWinrate: number; + firstUltFights: number; + firstUltWins: number; + firstUltWinrate: number; + dryFights: number; + dryFightWins: number; + dryFightWinrate: number; + nonDryFights: number; + totalUltsInNonDryFights: number; + avgUltsPerNonDryFight: number; + dryFightReversals: number; + dryFightReversalRate: number; + nonDryFightReversals: number; + nonDryFightReversalRate: number; + ultimateEfficiency: number; + avgUltsInWonFights: number; + avgUltsInLostFights: number; + wastedUltimates: number; + totalUltsUsed: number; +}; + +type FightEvent = Kill & { ultimate_id?: number }; + +type Fight = { + events: FightEvent[]; + start: number; + end: number; +}; + +type FightAnalysis = { + won: boolean; + hadFirstPick: boolean; + hadFirstDeath: boolean; + usedFirstUlt: boolean; + isDryFight: boolean; + isReversal: boolean; + ultCount: number; + wastedUlts: number; +}; + +function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { + const sortedEvents = [...fight.events].sort( + (a, b) => a.match_time - b.match_time + ); + + const kills = sortedEvents.filter( + (e) => e.event_type === "kill" || e.event_type === "mercy_rez" + ); + const ultimates = sortedEvents.filter( + (e) => e.event_type === "ultimate_start" + ); + + const ourUlts = ultimates.filter((u) => u.attacker_team === ourTeamName); + const ultCount = ourUlts.length; + const isDryFight = ultCount === 0; + + let ourKills = 0; + let enemyKills = 0; + let wastedUlts = 0; + let wasDown2Plus = false; + + for (const event of sortedEvents) { + if (event.event_type === "mercy_rez") { + if (event.victim_team === ourTeamName) { + enemyKills = Math.max(0, enemyKills - 1); + } else { + ourKills = Math.max(0, ourKills - 1); + } + } else if (event.event_type === "kill") { + if (event.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + + if (enemyKills - ourKills >= 2) wasDown2Plus = true; + + if ( + event.event_type === "ultimate_start" && + event.attacker_team === ourTeamName + ) { + const killDiff = ourKills - enemyKills; + if (killDiff <= -3) wastedUlts++; + } + } + + const won = ourKills > enemyKills; + const isReversal = won && wasDown2Plus; + + const firstKill = kills.find((k) => k.event_type === "kill"); + const hadFirstPick = firstKill + ? firstKill.attacker_team === ourTeamName + : false; + const hadFirstDeath = firstKill + ? firstKill.victim_team === ourTeamName + : false; + + const firstUlt = ultimates[0]; + const usedFirstUlt = firstUlt + ? firstUlt.attacker_team === ourTeamName + : false; + + return { + won, + hadFirstPick, + hadFirstDeath, + usedFirstUlt, + isDryFight, + isReversal, + ultCount, + wastedUlts, + }; +} + +function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { + const { + teamRosterSet, + mapDataIds, + allPlayerStats, + allKills, + allRezzes, + allUltimates, + } = sharedData; + + if (mapDataIds.length === 0) { + return emptyFightStats(); + } + + const teamNameByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (teamName) teamNameByMapId.set(mapDataId, teamName); + } + + const killsByMap = new Map(); + const rezzesByMap = new Map(); + const ultsByMap = new Map(); + + for (const kill of allKills) { + if (kill.MapDataId) { + if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); + killsByMap.get(kill.MapDataId)!.push(kill); + } + } + + for (const rez of allRezzes) { + if (rez.MapDataId) { + if (!rezzesByMap.has(rez.MapDataId)) rezzesByMap.set(rez.MapDataId, []); + rezzesByMap.get(rez.MapDataId)!.push(rez); + } + } + + for (const ult of allUltimates) { + if (ult.MapDataId) { + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); + ultsByMap.get(ult.MapDataId)!.push(ult); + } + } + + let totalFights = 0; + let fightsWon = 0; + let fightsLost = 0; + let firstPickFights = 0; + let firstPickWins = 0; + let firstDeathFights = 0; + let firstDeathWins = 0; + let firstUltFights = 0; + let firstUltWins = 0; + let dryFights = 0; + let dryFightWins = 0; + let dryFightReversals = 0; + let nonDryFightReversals = 0; + let nonDryFights = 0; + let totalUltsInNonDryFights = 0; + let totalUltsUsed = 0; + let ultsInWonFights = 0; + let ultsInLostFights = 0; + let totalWastedUlts = 0; + + for (const mapDataId of mapDataIds) { + const ourTeamName = teamNameByMapId.get(mapDataId); + if (!ourTeamName) continue; + + const kills = killsByMap.get(mapDataId) ?? []; + const rezzes = rezzesByMap.get(mapDataId) ?? []; + const ults = ultsByMap.get(mapDataId) ?? []; + + if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) + continue; + + const events: FightEvent[] = [ + ...kills, + ...rezzes.map((rez) => mercyRezToKillEvent(rez)), + ...ults.map((ult) => ({ + ...ultimateStartToKillEvent(ult), + ultimate_id: ult.ultimate_id, + })), + ]; + + events.sort((a, b) => a.match_time - b.match_time); + + const fights: Fight[] = []; + let currentFight: Fight | null = null; + + for (const event of events) { + if (!currentFight || event.match_time - currentFight.end > 15) { + currentFight = { + events: [event], + start: event.match_time, + end: event.match_time, + }; + fights.push(currentFight); + } else { + currentFight.events.push(event); + currentFight.end = event.match_time; + } + } + + for (const fight of fights) { + const analysis = analyzeFightOutcome(fight, ourTeamName); + + totalFights++; + if (analysis.won) fightsWon++; + else fightsLost++; + + if (analysis.hadFirstPick) { + firstPickFights++; + if (analysis.won) firstPickWins++; + } + if (analysis.hadFirstDeath) { + firstDeathFights++; + if (analysis.won) firstDeathWins++; + } + if (analysis.usedFirstUlt) { + firstUltFights++; + if (analysis.won) firstUltWins++; + } + if (analysis.isDryFight) { + dryFights++; + if (analysis.won) dryFightWins++; + if (analysis.isReversal) dryFightReversals++; + } else { + nonDryFights++; + totalUltsInNonDryFights += analysis.ultCount; + if (analysis.isReversal) nonDryFightReversals++; + } + + totalUltsUsed += analysis.ultCount; + totalWastedUlts += analysis.wastedUlts; + + if (analysis.won) ultsInWonFights += analysis.ultCount; + else ultsInLostFights += analysis.ultCount; + } + } + + const overallWinrate = totalFights > 0 ? (fightsWon / totalFights) * 100 : 0; + const firstPickWinrate = + firstPickFights > 0 ? (firstPickWins / firstPickFights) * 100 : 0; + const firstDeathWinrate = + firstDeathFights > 0 ? (firstDeathWins / firstDeathFights) * 100 : 0; + const firstUltWinrate = + firstUltFights > 0 ? (firstUltWins / firstUltFights) * 100 : 0; + const dryFightWinrate = dryFights > 0 ? (dryFightWins / dryFights) * 100 : 0; + const dryFightReversalRate = + dryFights > 0 ? (dryFightReversals / dryFights) * 100 : 0; + const nonDryFightReversalRate = + nonDryFights > 0 ? (nonDryFightReversals / nonDryFights) * 100 : 0; + const avgUltsPerNonDryFight = + nonDryFights > 0 ? totalUltsInNonDryFights / nonDryFights : 0; + const ultimateEfficiency = totalUltsUsed > 0 ? fightsWon / totalUltsUsed : 0; + const avgUltsInWonFights = fightsWon > 0 ? ultsInWonFights / fightsWon : 0; + const avgUltsInLostFights = + fightsLost > 0 ? ultsInLostFights / fightsLost : 0; + + return { + totalFights, + fightsWon, + fightsLost, + overallWinrate, + firstPickFights, + firstPickWins, + firstPickWinrate, + firstDeathFights, + firstDeathWins, + firstDeathWinrate, + firstUltFights, + firstUltWins, + firstUltWinrate, + dryFights, + dryFightWins, + dryFightWinrate, + dryFightReversals, + dryFightReversalRate, + nonDryFightReversals, + nonDryFightReversalRate, + nonDryFights, + totalUltsInNonDryFights, + avgUltsPerNonDryFight, + ultimateEfficiency, + avgUltsInWonFights, + avgUltsInLostFights, + wastedUltimates: totalWastedUlts, + totalUltsUsed, + }; +} + +function emptyFightStats(): TeamFightStats { + return { + totalFights: 0, + fightsWon: 0, + fightsLost: 0, + overallWinrate: 0, + firstPickFights: 0, + firstPickWins: 0, + firstPickWinrate: 0, + firstDeathFights: 0, + firstDeathWins: 0, + firstDeathWinrate: 0, + firstUltFights: 0, + firstUltWins: 0, + firstUltWinrate: 0, + dryFights: 0, + dryFightWins: 0, + dryFightWinrate: 0, + dryFightReversals: 0, + dryFightReversalRate: 0, + nonDryFightReversals: 0, + nonDryFightReversalRate: 0, + nonDryFights: 0, + totalUltsInNonDryFights: 0, + avgUltsPerNonDryFight: 0, + ultimateEfficiency: 0, + avgUltsInWonFights: 0, + avgUltsInLostFights: 0, + wastedUltimates: 0, + totalUltsUsed: 0, + }; +} + +export type TeamFightStatsServiceInterface = { + readonly getTeamFightStats: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamFightStatsService extends Context.Tag( + "@app/data/team/TeamFightStatsService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamFightStats( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const sharedData = yield* shared.getExtendedTeamData(teamId, { + dateRange, + }); + const result = processTeamFightStats(sharedData); + wideEvent.outcome = "success"; + wideEvent.total_fights = result.totalFights; + wideEvent.overall_winrate = result.overallWinrate; + yield* Metric.increment(fightStatsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(fightStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.fightStats.getTeamFightStats") + : Effect.logInfo("team.fightStats.getTeamFightStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(fightStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.fightStats.getTeamFightStats") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const fightStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamFightStats(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamFightStats: (teamId: number, dateRange?: TeamDateRange) => + fightStatsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamFightStatsServiceInterface; +}); + +export const TeamFightStatsServiceLive = Layer.effect( + TeamFightStatsService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/hero-pool-service.ts b/src/data/team/hero-pool-service.ts new file mode 100644 index 000000000..af44c2666 --- /dev/null +++ b/src/data/team/hero-pool-service.ts @@ -0,0 +1,758 @@ +import { determineRole } from "@/lib/player-table-data"; +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName } from "@/types/heroes"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import type { + MatchStart, + ObjectiveCaptured, + PayloadProgress, + PointProgress, + RoundEnd, +} from "@prisma/client"; +import { $Enums } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const heroPoolQuerySuccessTotal = Metric.counter( + "team.hero_pool.query.success", + { description: "Total successful team hero pool queries", incremental: true } +); + +const heroPoolQueryErrorTotal = Metric.counter("team.hero_pool.query.error", { + description: "Total team hero pool query failures", + incremental: true, +}); + +const heroPoolQueryDuration = Metric.histogram( + "team.hero_pool.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team hero pool query duration in milliseconds" +); + +export type HeroPlaytime = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + totalPlaytime: number; + gamesPlayed: number; + playedBy: string[]; +}; + +export type HeroWinrate = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + totalPlaytime: number; +}; + +export type HeroSpecialist = { + playerName: string; + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + playtime: number; + gamesPlayed: number; + ownershipPercentage: number; +}; + +export type HeroDiversity = { + totalUniqueHeroes: number; + heroesPerRole: { Tank: number; Damage: number; Support: number }; + diversityScore: number; + effectiveHeroPool: number; +}; + +export type HeroPoolAnalysis = { + mostPlayedByRole: { + Tank: HeroPlaytime[]; + Damage: HeroPlaytime[]; + Support: HeroPlaytime[]; + }; + topHeroWinrates: HeroWinrate[]; + specialists: HeroSpecialist[]; + diversity: HeroDiversity; +}; + +export type HeroPoolRawData = { + teamRoster: string[]; + mapDataRecords: { id: number; name: string | null; scrimDate: Date }[]; + allPlayerStats: { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + }[]; + matchStarts: MatchStart[]; + finalRounds: RoundEnd[]; + captures: ObjectiveCaptured[]; + payloadProgresses: PayloadProgress[]; + pointProgresses: PointProgress[]; +}; + +function createEmptyHeroPoolAnalysis(): HeroPoolAnalysis { + return { + mostPlayedByRole: { Tank: [], Damage: [], Support: [] }, + topHeroWinrates: [], + specialists: [], + diversity: { + totalUniqueHeroes: 0, + heroesPerRole: { Tank: 0, Damage: 0, Support: 0 }, + diversityScore: 0, + effectiveHeroPool: 0, + }, + }; +} + +function processHeroPoolAnalysis(sharedData: BaseTeamData): HeroPoolAnalysis { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + if (mapDataRecords.length === 0) return createEmptyHeroPoolAnalysis(); + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type HeroData = { + playtime: number; + gamesPlayed: Set; + playedBy: Set; + wins: number; + losses: number; + }; + type PlayerHeroData = { playtime: number; gamesPlayed: Set }; + + const heroDataMap = new Map(); + const playerHeroMap = new Map>(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + const isWin = winner === teamName; + + for (const stat of playersOnMap) { + const heroName = stat.player_hero as HeroName; + const playerName = stat.player_name; + + if (!heroDataMap.has(heroName)) { + heroDataMap.set(heroName, { + playtime: 0, + gamesPlayed: new Set(), + playedBy: new Set(), + wins: 0, + losses: 0, + }); + } + const heroData = heroDataMap.get(heroName)!; + heroData.playtime += stat.hero_time_played; + heroData.gamesPlayed.add(mapDataId); + heroData.playedBy.add(playerName); + if (isWin) heroData.wins++; + else heroData.losses++; + + if (!playerHeroMap.has(playerName)) + playerHeroMap.set(playerName, new Map()); + const playerHeroes = playerHeroMap.get(playerName)!; + if (!playerHeroes.has(heroName)) + playerHeroes.set(heroName, { playtime: 0, gamesPlayed: new Set() }); + const playerHeroData = playerHeroes.get(heroName)!; + playerHeroData.playtime += stat.hero_time_played; + playerHeroData.gamesPlayed.add(mapDataId); + } + } + + const mostPlayedByRole: HeroPoolAnalysis["mostPlayedByRole"] = { + Tank: [], + Damage: [], + Support: [], + }; + const allHeroWinrates: HeroWinrate[] = []; + + for (const [heroName, data] of heroDataMap.entries()) { + const role = determineRole(heroName); + if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; + + mostPlayedByRole[role].push({ + heroName, + role, + totalPlaytime: data.playtime, + gamesPlayed: data.gamesPlayed.size, + playedBy: Array.from(data.playedBy), + }); + + const gamesPlayed = data.wins + data.losses; + if (gamesPlayed > 0) { + allHeroWinrates.push({ + heroName, + role, + wins: data.wins, + losses: data.losses, + winrate: (data.wins / gamesPlayed) * 100, + gamesPlayed, + totalPlaytime: data.playtime, + }); + } + } + + mostPlayedByRole.Tank.sort((a, b) => b.totalPlaytime - a.totalPlaytime); + mostPlayedByRole.Damage.sort((a, b) => b.totalPlaytime - a.totalPlaytime); + mostPlayedByRole.Support.sort((a, b) => b.totalPlaytime - a.totalPlaytime); + + const topHeroWinrates = allHeroWinrates + .filter((h) => h.gamesPlayed >= 3) + .sort((a, b) => b.winrate - a.winrate) + .slice(0, 10); + + const specialists: HeroSpecialist[] = []; + for (const [playerName, heroesMap] of playerHeroMap.entries()) { + for (const [heroName, data] of heroesMap.entries()) { + const role = determineRole(heroName); + if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; + const totalHeroPlaytime = heroDataMap.get(heroName)?.playtime ?? 0; + const ownershipPercentage = + totalHeroPlaytime > 0 ? (data.playtime / totalHeroPlaytime) * 100 : 0; + if (ownershipPercentage >= 30) { + specialists.push({ + playerName, + heroName, + role, + playtime: data.playtime, + gamesPlayed: data.gamesPlayed.size, + ownershipPercentage, + }); + } + } + } + specialists.sort((a, b) => b.ownershipPercentage - a.ownershipPercentage); + + const uniqueHeroes = heroDataMap.size; + const heroesPerRole = { + Tank: mostPlayedByRole.Tank.length, + Damage: mostPlayedByRole.Damage.length, + Support: mostPlayedByRole.Support.length, + }; + const effectiveHeroPool = Array.from(heroDataMap.values()).filter( + (data) => data.gamesPlayed.size >= 3 + ).length; + const maxHeroes = 41; + const diversityScore = Math.min((uniqueHeroes / maxHeroes) * 100, 100); + + return { + mostPlayedByRole, + topHeroWinrates, + specialists, + diversity: { + totalUniqueHeroes: uniqueHeroes, + heroesPerRole, + diversityScore, + effectiveHeroPool, + }, + }; +} + +export type TeamHeroPoolServiceInterface = { + readonly getHeroPoolAnalysis: ( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ) => Effect.Effect; + + readonly getHeroPoolRawData: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamHeroPoolService extends Context.Tag( + "@app/data/team/TeamHeroPoolService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getHeroPoolAnalysis( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!(dateFrom && dateTo), + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + if (dateFrom && dateTo) { + // Custom date range path + const teamRoster = yield* shared.getTeamRoster(teamId); + const teamRosterSet = new Set(teamRoster); + + if (teamRoster.length === 0) { + wideEvent.outcome = "success"; + wideEvent.result = "empty"; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return createEmptyHeroPoolAnalysis(); + } + + const allMapDataRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { + Scrim: { + Team: { id: teamId }, + date: { gte: dateFrom, lte: dateTo }, + }, + }, + select: { id: true, name: true }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch maps for hero pool with date range", + cause: error, + }), + }); + + const mapDataRecords = allMapDataRecords.filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + return ( + mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash + ); + }); + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.result = "empty"; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return createEmptyHeroPoolAnalysis(); + } + + const mapDataIds = mapDataRecords.map((md) => md.id); + + const [ + allPlayerStats, + matchStarts, + finalRounds, + capturesData, + payloadProgresses, + pointProgresses, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + MapDataId: true, + eliminations: true, + final_blows: true, + deaths: true, + offensive_assists: true, + hero_damage_dealt: true, + damage_taken: true, + healing_dealt: true, + ultimates_earned: true, + ultimates_used: true, + }, + }), + prisma.matchStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { round_number: "desc" }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch hero pool data with date range", + cause: error, + }), + }); + + const baseData: BaseTeamData = { + teamId, + teamRoster, + teamRosterSet, + mapDataRecords, + mapDataIds, + allPlayerStats, + matchStarts, + finalRounds, + captures: capturesData, + payloadProgresses, + pointProgresses, + }; + + const result = processHeroPoolAnalysis(baseData); + wideEvent.outcome = "success"; + wideEvent.unique_heroes = result.diversity.totalUniqueHeroes; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return result; + } + + // Default path using shared data + const data = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + excludeClash: true, + }); + const result = processHeroPoolAnalysis(data); + wideEvent.outcome = "success"; + wideEvent.unique_heroes = result.diversity.totalUniqueHeroes; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroPoolQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.heroPool.getHeroPoolAnalysis") + : Effect.logInfo("team.heroPool.getHeroPoolAnalysis"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heroPoolQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.heroPool.getHeroPoolAnalysis") + ); + } + + function getHeroPoolRawData( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const scrimWhereClause: Record = { + Team: { id: teamId }, + }; + if (dateRange) { + scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; + } + + const allMapDataRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: scrimWhereClause }, + select: { id: true, name: true, Scrim: { select: { date: true } } }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch maps for hero pool raw data", + cause: error, + }), + }); + + const teamRoster = yield* shared.getTeamRoster(teamId); + + if (teamRoster.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return { + teamRoster: [], + mapDataRecords: [], + allPlayerStats: [], + matchStarts: [], + finalRounds: [], + captures: [], + payloadProgresses: [], + pointProgresses: [], + } satisfies HeroPoolRawData; + } + + const mapDataRecords = allMapDataRecords + .filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + return ( + mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash + ); + }) + .map((record) => ({ + id: record.id, + name: record.name, + scrimDate: record.Scrim?.date ?? new Date(), + })); + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(heroPoolQuerySuccessTotal); + return { + teamRoster, + mapDataRecords: [], + allPlayerStats: [], + matchStarts: [], + finalRounds: [], + captures: [], + payloadProgresses: [], + pointProgresses: [], + } satisfies HeroPoolRawData; + } + + const mapDataIds = mapDataRecords.map((md) => md.id); + + const [ + allPlayerStats, + matchStarts, + finalRounds, + capturesData, + payloadProgresses, + pointProgresses, + ] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + MapDataId: true, + }, + }), + prisma.matchStart.findMany({ + where: { MapDataId: { in: mapDataIds } }, + }), + prisma.roundEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: { round_number: "desc" }, + }), + prisma.objectiveCaptured.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [{ round_number: "asc" }, { match_time: "asc" }], + }), + prisma.payloadProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + prisma.pointProgress.findMany({ + where: { MapDataId: { in: mapDataIds } }, + orderBy: [ + { round_number: "asc" }, + { objective_index: "asc" }, + { match_time: "asc" }, + ], + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch hero pool raw data tables", + cause: error, + }), + }); + + wideEvent.outcome = "success"; + wideEvent.map_count = mapDataRecords.length; + yield* Metric.increment(heroPoolQuerySuccessTotal); + + return { + teamRoster, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures: capturesData, + payloadProgresses, + pointProgresses, + } satisfies HeroPoolRawData; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroPoolQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.heroPool.getHeroPoolRawData") + : Effect.logInfo("team.heroPool.getHeroPoolRawData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heroPoolQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.heroPool.getHeroPoolRawData") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + const heroPoolAnalysisCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const parsed = JSON.parse(rest) as { dateFrom?: string; dateTo?: string }; + const dateFrom = parsed.dateFrom ? new Date(parsed.dateFrom) : undefined; + const dateTo = parsed.dateTo ? new Date(parsed.dateTo) : undefined; + return getHeroPoolAnalysis(Number(teamIdStr), dateFrom, dateTo).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const heroPoolRawDataCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getHeroPoolRawData(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getHeroPoolAnalysis: (teamId: number, dateFrom?: Date, dateTo?: Date) => + heroPoolAnalysisCache + .get( + `${teamId}:${JSON.stringify({ dateFrom: dateFrom?.toISOString(), dateTo: dateTo?.toISOString() })}` + ) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getHeroPoolRawData: (teamId: number, dateRange?: TeamDateRange) => + heroPoolRawDataCache + .get(`${teamId}:${JSON.stringify(dateRange ?? {})}`) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamHeroPoolServiceInterface; +}); + +export const TeamHeroPoolServiceLive = Layer.effect( + TeamHeroPoolService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/hero-swap-service.ts b/src/data/team/hero-swap-service.ts new file mode 100644 index 000000000..59f2c37b6 --- /dev/null +++ b/src/data/team/hero-swap-service.ts @@ -0,0 +1,715 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import type { RoleName } from "@/types/heroes"; +import { getHeroRole } from "@/types/heroes"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const heroSwapQuerySuccessTotal = Metric.counter( + "team.hero_swap.query.success", + { description: "Total successful team hero swap queries", incremental: true } +); + +const heroSwapQueryErrorTotal = Metric.counter("team.hero_swap.query.error", { + description: "Total team hero swap query failures", + incremental: true, +}); + +const heroSwapQueryDuration = Metric.histogram( + "team.hero_swap.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team hero swap query duration in milliseconds" +); + +export type SwapTimingBucket = { + bucket: string; + count: number; + percentage: number; +}; +export type SwapWinrateBucket = { + label: string; + wins: number; + losses: number; + winrate: number; + totalMaps: number; +}; +export type SwapPair = { + fromHero: string; + toHero: string; + fromRole: RoleName; + toRole: RoleName; + count: number; + timingDistribution: SwapTimingBucket[]; +}; +export type PlayerSwapStats = { + playerName: string; + totalSwaps: number; + mapsWithSwaps: number; + mapsWithoutSwaps: number; + winrateWithSwaps: number; + winrateWithoutSwaps: number; + topSwapPair: { fromHero: string; toHero: string } | null; + topSwapPairCount: number; +}; +export type SwapTimingOutcome = { + label: string; + wins: number; + losses: number; + winrate: number; + totalMaps: number; +}; + +export type TeamHeroSwapStats = { + totalSwaps: number; + totalMaps: number; + swapsPerMap: number; + mapsWithSwaps: number; + mapsWithoutSwaps: number; + avgHeroTimeBeforeSwap: number; + noSwapWinrate: number; + noSwapWins: number; + noSwapLosses: number; + swapWinrate: number; + swapWins: number; + swapLosses: number; + timingDistribution: SwapTimingBucket[]; + winrateBySwapCount: SwapWinrateBucket[]; + topSwapPairs: SwapPair[]; + playerBreakdown: PlayerSwapStats[]; + timingOutcomes: SwapTimingOutcome[]; +}; + +export type SwapRecord = { + id: number; + match_time: number; + player_team: string; + player_name: string; + player_hero: string; + previous_hero: string; + hero_time_played: number; + MapDataId: number | null; +}; + +const UTILITY_SWAP_HEROES = new Set([ + "Symmetra", + "Lúcio", + "Juno", + "Widowmaker", +]); +const UTILITY_HERO_TIME_THRESHOLD = 25; +const ROUND_START_PROXIMITY_THRESHOLD = 15; + +export function filterUtilityRoundStartSwaps( + swaps: SwapRecord[], + roundStartTimes: number[] +): SwapRecord[] { + const sortedRoundStarts = [...roundStartTimes].sort((a, b) => a - b); + + function isNearRoundStart(matchTime: number): boolean { + for (const rs of sortedRoundStarts) { + if (Math.abs(matchTime - rs) <= ROUND_START_PROXIMITY_THRESHOLD) + return true; + if (rs > matchTime + ROUND_START_PROXIMITY_THRESHOLD) break; + } + return false; + } + + const idsToRemove = new Set(); + const byPlayer = new Map(); + for (const swap of swaps) { + const existing = byPlayer.get(swap.player_name) ?? []; + existing.push(swap); + byPlayer.set(swap.player_name, existing); + } + + for (const [, playerSwaps] of byPlayer) { + const sorted = playerSwaps.sort((a, b) => a.match_time - b.match_time); + for (let i = 0; i < sorted.length; i++) { + const swap = sorted[i]; + if (i < sorted.length - 1) { + const swapBack = sorted[i + 1]; + if ( + UTILITY_SWAP_HEROES.has(swap.player_hero) && + swapBack.previous_hero === swap.player_hero && + swapBack.hero_time_played < UTILITY_HERO_TIME_THRESHOLD && + isNearRoundStart(swap.match_time) + ) { + idsToRemove.add(swap.id); + idsToRemove.add(swapBack.id); + } + } + if ( + UTILITY_SWAP_HEROES.has(swap.previous_hero) && + swap.hero_time_played < UTILITY_HERO_TIME_THRESHOLD && + isNearRoundStart(swap.match_time) + ) { + idsToRemove.add(swap.id); + } + } + } + + return swaps.filter((s) => !idsToRemove.has(s.id)); +} + +function emptyStats(): TeamHeroSwapStats { + return { + totalSwaps: 0, + totalMaps: 0, + swapsPerMap: 0, + mapsWithSwaps: 0, + mapsWithoutSwaps: 0, + avgHeroTimeBeforeSwap: 0, + noSwapWinrate: 0, + noSwapWins: 0, + noSwapLosses: 0, + swapWinrate: 0, + swapWins: 0, + swapLosses: 0, + timingDistribution: [], + winrateBySwapCount: [], + topSwapPairs: [], + playerBreakdown: [], + timingOutcomes: [], + }; +} + +export type TeamHeroSwapServiceInterface = { + readonly getTeamHeroSwapStats: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamHeroSwapService extends Context.Tag( + "@app/data/team/TeamHeroSwapService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamHeroSwapStats( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const sharedData = yield* shared.getBaseTeamData(teamId, { dateRange }); + + if (sharedData.mapDataIds.length === 0) { + wideEvent.outcome = "success"; + wideEvent.total_swaps = 0; + yield* Metric.increment(heroSwapQuerySuccessTotal); + return emptyStats(); + } + + const [allHeroSwaps, matchEnds, roundStarts] = yield* Effect.tryPromise({ + try: () => + Promise.all([ + prisma.heroSwap.findMany({ + where: { + MapDataId: { in: sharedData.mapDataIds }, + match_time: { not: 0 }, + }, + select: { + id: true, + match_time: true, + player_team: true, + player_name: true, + player_hero: true, + previous_hero: true, + hero_time_played: true, + MapDataId: true, + }, + orderBy: { match_time: "asc" }, + }), + prisma.matchEnd.findMany({ + where: { MapDataId: { in: sharedData.mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + prisma.roundStart.findMany({ + where: { MapDataId: { in: sharedData.mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + ]), + catch: (error) => + new TeamQueryError({ + operation: "fetch hero swap data", + cause: error, + }), + }); + + const { + teamRosterSet, + mapDataIds, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const matchEndTimeMap = new Map(); + for (const me of matchEnds) { + if (me.MapDataId) { + const existing = matchEndTimeMap.get(me.MapDataId); + if (!existing || me.match_time > existing) + matchEndTimeMap.set(me.MapDataId, me.match_time); + } + } + + const roundStartsByMap = new Map(); + for (const rs of roundStarts) { + if (rs.MapDataId) { + const existing = roundStartsByMap.get(rs.MapDataId) ?? []; + existing.push(rs.match_time); + roundStartsByMap.set(rs.MapDataId, existing); + } + } + + const teamNameByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (teamName) teamNameByMapId.set(mapDataId, teamName); + } + + const winnerByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + winnerByMapId.set(mapDataId, winner); + } + + const swapsByMap = new Map(); + for (const swap of allHeroSwaps) { + if (!swap.MapDataId) continue; + const ourTeam = teamNameByMapId.get(swap.MapDataId); + if (!ourTeam || swap.player_team !== ourTeam) continue; + const existing = swapsByMap.get(swap.MapDataId) ?? []; + existing.push(swap); + swapsByMap.set(swap.MapDataId, existing); + } + + const filteredSwapsByMap = new Map(); + for (const [mapId, mapSwaps] of swapsByMap) { + const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; + const filtered = filterUtilityRoundStartSwaps(mapSwaps, rsTimesForMap); + if (filtered.length > 0) filteredSwapsByMap.set(mapId, filtered); + } + + const allFilteredSwaps: SwapRecord[] = []; + for (const swaps of filteredSwapsByMap.values()) + allFilteredSwaps.push(...swaps); + + const totalMaps = mapDataIds.length; + const mapsWithSwaps = filteredSwapsByMap.size; + const mapsWithoutSwaps = totalMaps - mapsWithSwaps; + const totalSwaps = allFilteredSwaps.length; + const swapsPerMap = totalMaps > 0 ? totalSwaps / totalMaps : 0; + + let totalHeroTimeBeforeSwap = 0; + for (const swap of allFilteredSwaps) + totalHeroTimeBeforeSwap += swap.hero_time_played; + const avgHeroTimeBeforeSwap = + totalSwaps > 0 ? totalHeroTimeBeforeSwap / totalSwaps : 0; + + let noSwapWins = 0, + noSwapLosses = 0, + swapWins = 0, + swapLosses = 0; + const swapCountByMap = new Map(); + + for (const mapDataId of mapDataIds) { + const ourTeam = teamNameByMapId.get(mapDataId); + if (!ourTeam) continue; + const winner = winnerByMapId.get(mapDataId); + if (winner === "N/A") continue; + const isWin = winner === ourTeam; + const mapSwapCount = filteredSwapsByMap.get(mapDataId)?.length ?? 0; + swapCountByMap.set(mapDataId, mapSwapCount); + if (mapSwapCount === 0) { + if (isWin) noSwapWins++; + else noSwapLosses++; + } else { + if (isWin) swapWins++; + else swapLosses++; + } + } + + const noSwapTotal = noSwapWins + noSwapLosses; + const swapTotal = swapWins + swapLosses; + const noSwapWinrate = + noSwapTotal > 0 ? (noSwapWins / noSwapTotal) * 100 : 0; + const swapWinrate = swapTotal > 0 ? (swapWins / swapTotal) * 100 : 0; + + const buckets = [ + { label: "0 swaps", min: 0, max: 0 }, + { label: "1 swap", min: 1, max: 1 }, + { label: "2 swaps", min: 2, max: 2 }, + { label: "3+ swaps", min: 3, max: Infinity }, + ]; + + const winrateBySwapCount: SwapWinrateBucket[] = buckets.map((bucket) => { + let wins = 0, + losses = 0; + for (const mapDataId of mapDataIds) { + const ourTeam = teamNameByMapId.get(mapDataId); + if (!ourTeam) continue; + const winner = winnerByMapId.get(mapDataId); + if (winner === "N/A") continue; + const count = swapCountByMap.get(mapDataId) ?? 0; + if (count >= bucket.min && count <= bucket.max) { + if (winner === ourTeam) wins++; + else losses++; + } + } + const total = wins + losses; + return { + label: bucket.label, + wins, + losses, + winrate: total > 0 ? (wins / total) * 100 : 0, + totalMaps: total, + }; + }); + + const timingBuckets = Array.from({ length: 10 }, (_, i) => ({ + bucket: `${i * 10}-${(i + 1) * 10}%`, + count: 0, + })); + for (const swap of allFilteredSwaps) { + if (!swap.MapDataId) continue; + const totalTime = matchEndTimeMap.get(swap.MapDataId); + if (!totalTime || totalTime <= 0) continue; + const pct = (swap.match_time / totalTime) * 100; + const bucketIndex = Math.min(Math.floor(pct / 10), 9); + timingBuckets[bucketIndex].count++; + } + const timingDistribution: SwapTimingBucket[] = timingBuckets.map((b) => ({ + bucket: b.bucket, + count: b.count, + percentage: totalSwaps > 0 ? (b.count / totalSwaps) * 100 : 0, + })); + + const pairCounts = new Map< + string, + { from: string; to: string; count: number; buckets: number[] } + >(); + for (const swap of allFilteredSwaps) { + const key = `${swap.previous_hero}->${swap.player_hero}`; + let entry = pairCounts.get(key); + if (!entry) { + entry = { + from: swap.previous_hero, + to: swap.player_hero, + count: 0, + buckets: new Array(10).fill(0), + }; + pairCounts.set(key, entry); + } + entry.count++; + if (swap.MapDataId) { + const totalTime = matchEndTimeMap.get(swap.MapDataId); + if (totalTime && totalTime > 0) { + const pct = (swap.match_time / totalTime) * 100; + const bi = Math.min(Math.floor(pct / 10), 9); + entry.buckets[bi]++; + } + } + } + + const topSwapPairs: SwapPair[] = Array.from(pairCounts.values()) + .sort((a, b) => b.count - a.count) + .slice(0, 10) + .map((p) => ({ + fromHero: p.from, + toHero: p.to, + fromRole: getHeroRole(p.from), + toRole: getHeroRole(p.to), + count: p.count, + timingDistribution: p.buckets.map((bucketCount, i) => ({ + bucket: `${i * 10}-${(i + 1) * 10}%`, + count: bucketCount, + percentage: p.count > 0 ? (bucketCount / p.count) * 100 : 0, + })), + })); + + const playerMapsPlayed = new Map>(); + for (const stat of allPlayerStats) { + if (!stat.MapDataId || !teamRosterSet.has(stat.player_name)) continue; + const ourTeam = teamNameByMapId.get(stat.MapDataId); + if (!ourTeam || stat.player_team !== ourTeam) continue; + const existing = playerMapsPlayed.get(stat.player_name) ?? new Set(); + existing.add(stat.MapDataId); + playerMapsPlayed.set(stat.player_name, existing); + } + + const playerSwapsByMap = new Map>(); + const playerPairCounts = new Map>(); + for (const swap of allFilteredSwaps) { + if (!swap.MapDataId) continue; + let mapMap = playerSwapsByMap.get(swap.player_name); + if (!mapMap) { + mapMap = new Map(); + playerSwapsByMap.set(swap.player_name, mapMap); + } + const existing = mapMap.get(swap.MapDataId) ?? []; + existing.push(swap); + mapMap.set(swap.MapDataId, existing); + let pairs = playerPairCounts.get(swap.player_name); + if (!pairs) { + pairs = new Map(); + playerPairCounts.set(swap.player_name, pairs); + } + const pairKey = `${swap.previous_hero}->${swap.player_hero}`; + pairs.set(pairKey, (pairs.get(pairKey) ?? 0) + 1); + } + + const playerBreakdown: PlayerSwapStats[] = []; + for (const [playerName, mapsSet] of playerMapsPlayed) { + const playerMapSwaps = playerSwapsByMap.get(playerName); + const mapsWithPlayerSwaps = new Set(); + let totalPlayerSwaps = 0; + if (playerMapSwaps) { + for (const [mapId, swaps] of playerMapSwaps) { + mapsWithPlayerSwaps.add(mapId); + totalPlayerSwaps += swaps.length; + } + } + const mapsWithout = new Set(); + for (const mapId of mapsSet) { + if (!mapsWithPlayerSwaps.has(mapId)) mapsWithout.add(mapId); + } + let winsWithSwaps = 0, + lossesWithSwaps = 0; + for (const mapId of mapsWithPlayerSwaps) { + const ourTeam = teamNameByMapId.get(mapId); + if (!ourTeam) continue; + const winner = winnerByMapId.get(mapId); + if (winner === "N/A") continue; + if (winner === ourTeam) winsWithSwaps++; + else lossesWithSwaps++; + } + let winsWithout = 0, + lossesWithout = 0; + for (const mapId of mapsWithout) { + const ourTeam = teamNameByMapId.get(mapId); + if (!ourTeam) continue; + const winner = winnerByMapId.get(mapId); + if (winner === "N/A") continue; + if (winner === ourTeam) winsWithout++; + else lossesWithout++; + } + const totalWith = winsWithSwaps + lossesWithSwaps; + const totalWithout = winsWithout + lossesWithout; + let topPairKey: string | null = null; + let topPairCount = 0; + const pairs = playerPairCounts.get(playerName); + if (pairs) { + for (const [key, count] of pairs) { + if (count > topPairCount) { + topPairCount = count; + topPairKey = key; + } + } + } + let topSwapPair: PlayerSwapStats["topSwapPair"] = null; + if (topPairKey) { + const [from, to] = topPairKey.split("->"); + topSwapPair = { fromHero: from, toHero: to }; + } + playerBreakdown.push({ + playerName, + totalSwaps: totalPlayerSwaps, + mapsWithSwaps: mapsWithPlayerSwaps.size, + mapsWithoutSwaps: mapsWithout.size, + winrateWithSwaps: + totalWith > 0 ? (winsWithSwaps / totalWith) * 100 : 0, + winrateWithoutSwaps: + totalWithout > 0 ? (winsWithout / totalWithout) * 100 : 0, + topSwapPair, + topSwapPairCount: topPairCount, + }); + } + playerBreakdown.sort((a, b) => b.totalSwaps - a.totalSwaps); + + const timingLabels = ["Early (0-33%)", "Mid (33-66%)", "Late (66-100%)"]; + const timingMapSets: { wins: Set; losses: Set }[] = [ + { wins: new Set(), losses: new Set() }, + { wins: new Set(), losses: new Set() }, + { wins: new Set(), losses: new Set() }, + ]; + for (const swap of allFilteredSwaps) { + if (!swap.MapDataId) continue; + const totalTime = matchEndTimeMap.get(swap.MapDataId); + if (!totalTime || totalTime <= 0) continue; + const pct = (swap.match_time / totalTime) * 100; + let timingIndex: number; + if (pct < 33.33) timingIndex = 0; + else if (pct < 66.67) timingIndex = 1; + else timingIndex = 2; + const ourTeam = teamNameByMapId.get(swap.MapDataId); + if (!ourTeam) continue; + const winner = winnerByMapId.get(swap.MapDataId); + if (winner === "N/A") continue; + if (winner === ourTeam) + timingMapSets[timingIndex].wins.add(swap.MapDataId); + else timingMapSets[timingIndex].losses.add(swap.MapDataId); + } + const timingOutcomes: SwapTimingOutcome[] = timingLabels.map( + (label, i) => { + const wins = timingMapSets[i].wins.size; + const losses = timingMapSets[i].losses.size; + const total = wins + losses; + return { + label, + wins, + losses, + winrate: total > 0 ? (wins / total) * 100 : 0, + totalMaps: total, + }; + } + ); + + wideEvent.outcome = "success"; + wideEvent.total_swaps = totalSwaps; + wideEvent.maps_with_swaps = mapsWithSwaps; + yield* Metric.increment(heroSwapQuerySuccessTotal); + + return { + totalSwaps, + totalMaps, + swapsPerMap, + mapsWithSwaps, + mapsWithoutSwaps, + avgHeroTimeBeforeSwap, + noSwapWinrate, + noSwapWins, + noSwapLosses, + swapWinrate, + swapWins, + swapLosses, + timingDistribution, + winrateBySwapCount, + topSwapPairs, + playerBreakdown, + timingOutcomes, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(heroSwapQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.heroSwap.getTeamHeroSwapStats") + : Effect.logInfo("team.heroSwap.getTeamHeroSwapStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(heroSwapQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.heroSwap.getTeamHeroSwapStats") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const heroSwapStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamHeroSwapStats(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamHeroSwapStats: (teamId: number, dateRange?: TeamDateRange) => + heroSwapStatsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamHeroSwapServiceInterface; +}); + +export const TeamHeroSwapServiceLive = Layer.effect( + TeamHeroSwapService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/index.ts b/src/data/team/index.ts new file mode 100644 index 000000000..d0a8d4874 --- /dev/null +++ b/src/data/team/index.ts @@ -0,0 +1,161 @@ +export { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; +export type { + TeamSharedDataServiceInterface, + BaseTeamDataOptions, +} from "./shared-data-service"; + +export { + findTeamNameForMapInMemory, + buildFinalRoundMap, + buildMatchStartMap, + buildCapturesMaps, + buildProgressMaps, +} from "./shared-core"; +export type { + BaseTeamData, + ExtendedTeamData, + TeamDateRange, +} from "./shared-core"; + +export { TeamNotFoundError, TeamQueryError } from "./errors"; + +export { TeamIdSchema, BaseTeamDataOptionsSchema } from "./types"; + +export { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; +export type { + TeamStatsServiceInterface, + TeamWinrates, + TopMapByPlaytime, + BestMapByWinrate, +} from "./stats-service"; + +export { TeamTrendsService, TeamTrendsServiceLive } from "./trends-service"; +export type { + TeamTrendsServiceInterface, + WinrateDataPoint, + RecentFormMatch, + RecentForm, + StreakInfo, +} from "./trends-service"; + +export { + TeamFightStatsService, + TeamFightStatsServiceLive, +} from "./fight-stats-service"; +export type { + TeamFightStatsServiceInterface, + TeamFightStats, +} from "./fight-stats-service"; + +export { + TeamRoleStatsService, + TeamRoleStatsServiceLive, +} from "./role-stats-service"; +export type { + TeamRoleStatsServiceInterface, + RoleStats, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, +} from "./role-stats-service"; + +export { + TeamHeroPoolService, + TeamHeroPoolServiceLive, +} from "./hero-pool-service"; +export type { + TeamHeroPoolServiceInterface, + HeroPlaytime, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, +} from "./hero-pool-service"; + +export { + TeamHeroSwapService, + TeamHeroSwapServiceLive, +} from "./hero-swap-service"; +export type { + TeamHeroSwapServiceInterface, + TeamHeroSwapStats, + SwapTimingBucket, + SwapWinrateBucket, + SwapPair, + PlayerSwapStats, + SwapTimingOutcome, +} from "./hero-swap-service"; + +export { TeamMapModeService, TeamMapModeServiceLive } from "./map-mode-service"; +export type { + TeamMapModeServiceInterface, + MapModeStats, + MapModePerformance, +} from "./map-mode-service"; + +export { + TeamQuickWinsService, + TeamQuickWinsServiceLive, +} from "./quick-wins-service"; +export type { + TeamQuickWinsServiceInterface, + QuickWinsStats, +} from "./quick-wins-service"; + +export { TeamUltService, TeamUltServiceLive } from "./ult-service"; +export type { + TeamUltServiceInterface, + ScenarioStats, + HeroUltImpact, + UltImpactAnalysis, + TeamUltRoleBreakdown, + PlayerUltRanking, + FightOpeningHero, + TeamUltStats, +} from "./ult-service"; + +export { + TeamBanImpactService, + TeamBanImpactServiceLive, +} from "./ban-impact-service"; +export type { + TeamBanImpactServiceInterface, + HeroBanImpact, + TeamBanImpactAnalysis, + OurBanImpact, + TeamOurBanAnalysis, + CombinedBanAnalysis, +} from "./ban-impact-service"; + +export { + TeamAbilityImpactService, + TeamAbilityImpactServiceLive, +} from "./ability-impact-service"; +export type { + TeamAbilityImpactServiceInterface, + AbilityScenarioStats, + AbilityImpactData, + HeroAbilityImpact, + AbilityImpactAnalysis, +} from "./ability-impact-service"; + +export { + TeamComparisonService, + TeamComparisonServiceLive, +} from "./comparison-service"; +export type { TeamComparisonServiceInterface } from "./comparison-service"; + +export { TeamMatchupService, TeamMatchupServiceLive } from "./matchup-service"; +export type { + TeamMatchupServiceInterface, + MapHeroEntry, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./matchup-service"; diff --git a/src/data/team/map-mode-service.ts b/src/data/team/map-mode-service.ts new file mode 100644 index 000000000..807091611 --- /dev/null +++ b/src/data/team/map-mode-service.ts @@ -0,0 +1,408 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import { teamCacheMissTotal, teamCacheRequestTotal } from "./metrics"; +import type { TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const mapModeQuerySuccessTotal = Metric.counter("team.map_mode.query.success", { + description: "Total successful team map mode queries", + incremental: true, +}); + +const mapModeQueryErrorTotal = Metric.counter("team.map_mode.query.error", { + description: "Total team map mode query failures", + incremental: true, +}); + +const mapModeQueryDuration = Metric.histogram( + "team.map_mode.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team map mode query duration in milliseconds" +); + +export type MapModeStats = { + mapType: $Enums.MapType; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + avgPlaytime: number; + bestMap: { name: string; winrate: number } | null; + worstMap: { name: string; winrate: number } | null; +}; + +export type MapModePerformance = { + overall: { + totalGames: number; + totalWins: number; + totalLosses: number; + overallWinrate: number; + }; + byMode: Record<$Enums.MapType, MapModeStats>; + bestMode: $Enums.MapType | null; + worstMode: $Enums.MapType | null; +}; + +function createEmptyMapModePerformance(): MapModePerformance { + const emptyStats: MapModeStats = { + mapType: $Enums.MapType.Control, + wins: 0, + losses: 0, + winrate: 0, + gamesPlayed: 0, + avgPlaytime: 0, + bestMap: null, + worstMap: null, + }; + return { + overall: { totalGames: 0, totalWins: 0, totalLosses: 0, overallWinrate: 0 }, + byMode: { + [$Enums.MapType.Control]: { + ...emptyStats, + mapType: $Enums.MapType.Control, + }, + [$Enums.MapType.Hybrid]: { + ...emptyStats, + mapType: $Enums.MapType.Hybrid, + }, + [$Enums.MapType.Escort]: { + ...emptyStats, + mapType: $Enums.MapType.Escort, + }, + [$Enums.MapType.Push]: { ...emptyStats, mapType: $Enums.MapType.Push }, + [$Enums.MapType.Clash]: { ...emptyStats, mapType: $Enums.MapType.Clash }, + [$Enums.MapType.Flashpoint]: { + ...emptyStats, + mapType: $Enums.MapType.Flashpoint, + }, + }, + bestMode: null, + worstMode: null, + }; +} + +export type TeamMapModeServiceInterface = { + readonly getMapModePerformance: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamMapModeService extends Context.Tag( + "@app/data/team/TeamMapModeService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getMapModePerformance( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const sharedData = yield* shared.getBaseTeamData(teamId, { dateRange }); + const { mapDataRecords: allMapDataRecords } = sharedData; + + if (allMapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.total_games = 0; + yield* Metric.increment(mapModeQuerySuccessTotal); + return createEmptyMapModePerformance(); + } + + const mapDataIds = allMapDataRecords.map((md) => md.id); + + const matchEnds = yield* Effect.tryPromise({ + try: () => + prisma.matchEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch match ends for map mode", + cause: error, + }), + }); + + const { + teamRosterSet, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + + const matchEndMap = new Map(); + for (const match of matchEnds) { + if (match.MapDataId) matchEndMap.set(match.MapDataId, match.match_time); + } + + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type MapModeData = { + wins: number; + losses: number; + totalPlaytime: number; + mapWinrates: Map; + }; + const modeData = new Map<$Enums.MapType, MapModeData>(); + + const activeModes = [ + $Enums.MapType.Control, + $Enums.MapType.Hybrid, + $Enums.MapType.Escort, + $Enums.MapType.Flashpoint, + ]; + for (const mapType of activeModes) { + modeData.set(mapType, { + wins: 0, + losses: 0, + totalPlaytime: 0, + mapWinrates: new Map(), + }); + } + + let totalGames = 0, + totalWins = 0, + totalLosses = 0; + + for (const mapDataRecord of allMapDataRecords) { + const mapDataId = mapDataRecord.id; + const mapName = mapDataRecord.name; + if (!mapName) continue; + + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + if (!mapType) continue; + if (mapType === $Enums.MapType.Push || mapType === $Enums.MapType.Clash) + continue; + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => + stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) + continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + const playtime = matchEndMap.get(mapDataId) ?? 0; + + const data = modeData.get(mapType)!; + if (isWin) { + data.wins++; + totalWins++; + } else { + data.losses++; + totalLosses++; + } + data.totalPlaytime += playtime; + totalGames++; + + if (!data.mapWinrates.has(mapName)) + data.mapWinrates.set(mapName, { wins: 0, losses: 0 }); + const mapWinrate = data.mapWinrates.get(mapName)!; + if (isWin) mapWinrate.wins++; + else mapWinrate.losses++; + } + + // oxlint-disable-next-line typescript-eslint/consistent-type-assertions + const byMode = {} as Record<$Enums.MapType, MapModeStats>; + let bestMode: $Enums.MapType | null = null; + let bestWinrate = -1; + let worstMode: $Enums.MapType | null = null; + let worstWinrate = 101; + + for (const [mapType, data] of modeData.entries()) { + const gamesPlayed = data.wins + data.losses; + const winrate = gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; + const avgPlaytime = + gamesPlayed > 0 ? data.totalPlaytime / gamesPlayed : 0; + + let bestMap: MapModeStats["bestMap"] = null; + let worstMap: MapModeStats["worstMap"] = null; + + if (data.mapWinrates.size > 0) { + let bestMapWinrate = -1; + let worstMapWinrate = 101; + for (const [mn, mapData] of data.mapWinrates.entries()) { + const mapGames = mapData.wins + mapData.losses; + if (mapGames === 0) continue; + const mapWinrateVal = (mapData.wins / mapGames) * 100; + if (mapWinrateVal > bestMapWinrate) { + bestMapWinrate = mapWinrateVal; + bestMap = { name: mn, winrate: mapWinrateVal }; + } + if (mapWinrateVal < worstMapWinrate) { + worstMapWinrate = mapWinrateVal; + worstMap = { name: mn, winrate: mapWinrateVal }; + } + } + } + + byMode[mapType] = { + mapType, + wins: data.wins, + losses: data.losses, + winrate, + gamesPlayed, + avgPlaytime, + bestMap, + worstMap, + }; + + if (gamesPlayed >= 3) { + if (winrate > bestWinrate) { + bestWinrate = winrate; + bestMode = mapType; + } + if (winrate < worstWinrate) { + worstWinrate = winrate; + worstMode = mapType; + } + } + } + + const overallWinrate = + totalGames > 0 ? (totalWins / totalGames) * 100 : 0; + + wideEvent.outcome = "success"; + wideEvent.total_games = totalGames; + wideEvent.best_mode = bestMode; + yield* Metric.increment(mapModeQuerySuccessTotal); + + return { + overall: { totalGames, totalWins, totalLosses, overallWinrate }, + byMode, + bestMode, + worstMode, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(mapModeQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.mapMode.getMapModePerformance") + : Effect.logInfo("team.mapMode.getMapModePerformance"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(mapModeQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.mapMode.getMapModePerformance") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const mapModeCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getMapModePerformance(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getMapModePerformance: (teamId: number, dateRange?: TeamDateRange) => + mapModeCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamMapModeServiceInterface; +}); + +export const TeamMapModeServiceLive = Layer.effect( + TeamMapModeService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/matchup-service.ts b/src/data/team/matchup-service.ts new file mode 100644 index 000000000..c1cf7c092 --- /dev/null +++ b/src/data/team/matchup-service.ts @@ -0,0 +1,485 @@ +import { determineRole } from "@/lib/player-table-data"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName } from "@/types/heroes"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import type { TeamQueryError } from "./errors"; +import { teamCacheMissTotal, teamCacheRequestTotal } from "./metrics"; +import type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const matchupQuerySuccessTotal = Metric.counter("team.matchup.query.success", { + description: "Total successful team matchup queries", + incremental: true, +}); +const matchupQueryErrorTotal = Metric.counter("team.matchup.query.error", { + description: "Total team matchup query failures", + incremental: true, +}); +const matchupQueryDuration = Metric.histogram( + "team.matchup.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team matchup query duration in milliseconds" +); + +export type MapHeroEntry = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + playerName: string; + timePlayed: number; +}; + +export type MatchupMapResult = { + mapDataId: number; + mapName: string; + scrimName: string; + date: string; + isWin: boolean; + ourHeroes: MapHeroEntry[]; + enemyHeroes: MapHeroEntry[]; +}; + +export type MatchupWinrateData = { + maps: MatchupMapResult[]; + allOurHeroes: HeroName[]; + allEnemyHeroes: HeroName[]; +}; + +export type EnemyHeroWinrate = { + heroName: HeroName; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; +}; + +export type EnemyHeroAnalysis = { + winrateVsHero: EnemyHeroWinrate[]; +}; + +const MIN_GAMES_FOR_INCLUSION = 2; + +function buildHeroEntries( + playerStats: { + player_name: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + }[] +): MapHeroEntry[] { + const playerMap = new Map(); + for (const stat of playerStats) { + const existing = playerMap.get(stat.player_name); + if (!existing || stat.hero_time_played > existing.timePlayed) { + playerMap.set(stat.player_name, { + heroName: stat.player_hero, + timePlayed: stat.hero_time_played, + }); + } + } + + const entries: MapHeroEntry[] = []; + for (const [playerName, data] of playerMap.entries()) { + const role = determineRole(data.heroName as HeroName); + if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; + entries.push({ + heroName: data.heroName as HeroName, + role, + playerName, + timePlayed: data.timePlayed, + }); + } + + const roleOrder = { Tank: 0, Damage: 1, Support: 2 }; + entries.sort( + (a, b) => + roleOrder[a.role] - roleOrder[b.role] || + a.heroName.localeCompare(b.heroName) + ); + return entries.slice(0, 5); +} + +function processMatchupWinrateData( + sharedData: BaseTeamData +): MatchupWinrateData { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + if (mapDataRecords.length === 0) + return { maps: [], allOurHeroes: [], allEnemyHeroes: [] }; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const maps: MatchupMapResult[] = []; + const allOurHeroesSet = new Set(); + const allEnemyHeroesSet = new Set(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + const isWin = winner === teamName; + + const enemyPlayers = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team !== teamName + ); + const ourHeroes = buildHeroEntries(playersOnMap); + const enemyHeroes = buildHeroEntries(enemyPlayers); + + for (const h of ourHeroes) allOurHeroesSet.add(h.heroName); + for (const h of enemyHeroes) allEnemyHeroesSet.add(h.heroName); + + const scrim = (mapDataRecord as { Scrim?: { name: string; date: Date } }) + .Scrim; + + maps.push({ + mapDataId, + mapName: mapDataRecord.name ?? "Unknown", + scrimName: scrim?.name ?? "Unknown", + date: scrim?.date?.toISOString() ?? new Date().toISOString(), + isWin, + ourHeroes, + enemyHeroes, + }); + } + + maps.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + return { + maps, + allOurHeroes: Array.from(allOurHeroesSet).sort(), + allEnemyHeroes: Array.from(allEnemyHeroesSet).sort(), + }; +} + +function processEnemyHeroAnalysis(sharedData: BaseTeamData): EnemyHeroAnalysis { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + if (mapDataRecords.length === 0) return { winrateVsHero: [] }; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const enemyHeroData = new Map< + string, + { wins: number; losses: number; maps: Set } + >(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + const isWin = winner === teamName; + + const enemyPlayers = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team !== teamName + ); + const seenHeroes = new Set(); + for (const enemy of enemyPlayers) { + const hero = enemy.player_hero; + if (seenHeroes.has(hero)) continue; + seenHeroes.add(hero); + + if (!enemyHeroData.has(hero)) + enemyHeroData.set(hero, { wins: 0, losses: 0, maps: new Set() }); + const data = enemyHeroData.get(hero)!; + if (!data.maps.has(mapDataId)) { + data.maps.add(mapDataId); + if (isWin) data.wins++; + else data.losses++; + } + } + } + + const winrateVsHero: EnemyHeroWinrate[] = []; + for (const [heroName, data] of enemyHeroData.entries()) { + const gamesPlayed = data.wins + data.losses; + if (gamesPlayed < MIN_GAMES_FOR_INCLUSION) continue; + winrateVsHero.push({ + heroName: heroName as HeroName, + wins: data.wins, + losses: data.losses, + winrate: (data.wins / gamesPlayed) * 100, + gamesPlayed, + }); + } + winrateVsHero.sort((a, b) => b.gamesPlayed - a.gamesPlayed); + + return { winrateVsHero }; +} + +export type TeamMatchupServiceInterface = { + readonly getMatchupWinrateData: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getEnemyHeroAnalysis: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamMatchupService extends Context.Tag( + "@app/data/team/TeamMatchupService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getMatchupWinrateData( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + excludeClash: true, + includeDateInfo: true, + dateRange, + }); + const result = processMatchupWinrateData(data); + wideEvent.outcome = "success"; + wideEvent.map_count = result.maps.length; + wideEvent.our_hero_count = result.allOurHeroes.length; + wideEvent.enemy_hero_count = result.allEnemyHeroes.length; + yield* Metric.increment(matchupQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(matchupQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.matchup.getMatchupWinrateData") + : Effect.logInfo("team.matchup.getMatchupWinrateData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(matchupQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.matchup.getMatchupWinrateData") + ); + } + + function getEnemyHeroAnalysis( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + excludeClash: true, + dateRange, + }); + const result = processEnemyHeroAnalysis(data); + wideEvent.outcome = "success"; + wideEvent.enemy_hero_count = result.winrateVsHero.length; + yield* Metric.increment(matchupQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(matchupQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.matchup.getEnemyHeroAnalysis") + : Effect.logInfo("team.matchup.getEnemyHeroAnalysis"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(matchupQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.matchup.getEnemyHeroAnalysis") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const matchupWinrateCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getMatchupWinrateData(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const enemyHeroCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getEnemyHeroAnalysis(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getMatchupWinrateData: (teamId: number, dateRange?: TeamDateRange) => + matchupWinrateCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getEnemyHeroAnalysis: (teamId: number, dateRange?: TeamDateRange) => + enemyHeroCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamMatchupServiceInterface; +}); + +export const TeamMatchupServiceLive = Layer.effect( + TeamMatchupService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/quick-wins-service.ts b/src/data/team/quick-wins-service.ts new file mode 100644 index 000000000..7350012fa --- /dev/null +++ b/src/data/team/quick-wins-service.ts @@ -0,0 +1,472 @@ +import { mercyRezToKillEvent, ultimateStartToKillEvent } from "@/lib/utils"; +import { calculateWinner } from "@/lib/winrate"; +import type { Kill } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import type { TeamQueryError } from "./errors"; +import type { TeamFightStats } from "./fight-stats-service"; +import { + TeamFightStatsService, + TeamFightStatsServiceLive, +} from "./fight-stats-service"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const quickWinsQuerySuccessTotal = Metric.counter( + "team.quick_wins.query.success", + { description: "Total successful team quick wins queries", incremental: true } +); + +const quickWinsQueryErrorTotal = Metric.counter("team.quick_wins.query.error", { + description: "Total team quick wins query failures", + incremental: true, +}); + +const quickWinsQueryDuration = Metric.histogram( + "team.quick_wins.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team quick wins query duration in milliseconds" +); + +export type QuickWinsStats = { + last10GamesPerformance: { wins: number; losses: number; winrate: number }; + bestDayOfWeek: { + day: string; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + } | null; + averageFightDuration: number | null; + firstPickSuccessRate: { + successfulFirstPicks: number; + totalFirstPicks: number; + successRate: number; + } | null; +}; + +type FightEvent = Kill & { ultimate_id?: number }; +type Fight = { events: FightEvent[]; start: number; end: number }; + +function analyzeFightOutcome( + fight: Fight, + ourTeamName: string +): { won: boolean; hadFirstPick: boolean } { + const sortedEvents = [...fight.events].sort( + (a, b) => a.match_time - b.match_time + ); + const kills = sortedEvents.filter( + (e) => e.event_type === "kill" || e.event_type === "mercy_rez" + ); + + let ourKills = 0; + let enemyKills = 0; + + for (const kill of kills) { + if (kill.event_type === "mercy_rez") { + if (kill.victim_team === ourTeamName) + enemyKills = Math.max(0, enemyKills - 1); + else ourKills = Math.max(0, ourKills - 1); + } else { + if (kill.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + } + + const won = ourKills > enemyKills; + const firstKill = kills.find((k) => k.event_type === "kill"); + const hadFirstPick = firstKill + ? firstKill.attacker_team === ourTeamName + : false; + + return { won, hadFirstPick }; +} + +function processQuickWinsStats( + sharedData: ExtendedTeamData, + fightStats: TeamFightStats +): QuickWinsStats { + const { + teamRosterSet, + mapDataRecords: rawMapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + allKills, + allRezzes, + allUltimates, + } = sharedData; + + const mapDataRecords = rawMapDataRecords as { + id: number; + name: string | null; + Scrim?: { id: number; name: string; date: Date }; + }[]; + + if (mapDataRecords.length === 0) { + return { + last10GamesPerformance: { wins: 0, losses: 0, winrate: 0 }, + bestDayOfWeek: null, + averageFightDuration: null, + firstPickSuccessRate: null, + }; + } + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type MatchResult = { + mapDataId: number; + scrimDate: Date; + teamName: string; + isWin: boolean; + }; + const matchResults: MatchResult[] = []; + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const scrimDate = mapDataRecord.Scrim?.date ?? new Date(); + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + matchResults.push({ + mapDataId, + scrimDate, + teamName, + isWin: winner === teamName, + }); + } + + const last10Games = matchResults.slice(0, 10); + const last10Wins = last10Games.filter((m) => m.isWin).length; + const last10Losses = last10Games.length - last10Wins; + const last10Winrate = + last10Games.length > 0 ? (last10Wins / last10Games.length) * 100 : 0; + + const dayStats = new Map< + number, + { wins: number; losses: number; day: string } + >(); + const daysOfWeek = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + + for (const result of matchResults) { + const dayOfWeek = result.scrimDate.getDay(); + if (!dayStats.has(dayOfWeek)) + dayStats.set(dayOfWeek, { + wins: 0, + losses: 0, + day: daysOfWeek[dayOfWeek], + }); + const stats = dayStats.get(dayOfWeek)!; + if (result.isWin) stats.wins++; + else stats.losses++; + } + + let bestDay = null; + let bestWinrate = -1; + for (const [, stats] of dayStats) { + const total = stats.wins + stats.losses; + if (total >= 3) { + const winrate = (stats.wins / total) * 100; + if (winrate > bestWinrate) { + bestWinrate = winrate; + bestDay = { + day: stats.day, + wins: stats.wins, + losses: stats.losses, + winrate, + gamesPlayed: total, + }; + } + } + } + + const killsByMap = new Map(); + const rezzesByMap = new Map(); + const ultsByMap = new Map(); + + for (const kill of allKills) { + if (kill.MapDataId) { + if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); + killsByMap.get(kill.MapDataId)!.push(kill); + } + } + for (const rez of allRezzes) { + if (rez.MapDataId) { + if (!rezzesByMap.has(rez.MapDataId)) rezzesByMap.set(rez.MapDataId, []); + rezzesByMap.get(rez.MapDataId)!.push(rez); + } + } + for (const ult of allUltimates) { + if (ult.MapDataId) { + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); + ultsByMap.get(ult.MapDataId)!.push(ult); + } + } + + let totalFightDuration = 0; + let fightCount = 0; + let successfulFirstPicks = 0; + let totalFirstPicks = 0; + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const ourTeamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!ourTeamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === ourTeamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const kills = killsByMap.get(mapDataId) ?? []; + const rezzes = rezzesByMap.get(mapDataId) ?? []; + const ults = ultsByMap.get(mapDataId) ?? []; + + if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) + continue; + + const events: FightEvent[] = [ + ...kills, + ...rezzes.map((rez) => mercyRezToKillEvent(rez)), + ...ults.map((ult) => ({ + ...ultimateStartToKillEvent(ult), + ultimate_id: ult.ultimate_id, + })), + ]; + events.sort((a, b) => a.match_time - b.match_time); + + const fights: Fight[] = []; + let currentFight: Fight | null = null; + for (const event of events) { + if (!currentFight || event.match_time - currentFight.end > 15) { + currentFight = { + events: [event], + start: event.match_time, + end: event.match_time, + }; + fights.push(currentFight); + } else { + currentFight.events.push(event); + currentFight.end = event.match_time; + } + } + + for (const fight of fights) { + const duration = fight.end - fight.start; + if (duration > 0 && duration < 300) { + totalFightDuration += duration; + fightCount++; + } + const analysis = analyzeFightOutcome(fight, ourTeamName); + if (analysis.hadFirstPick) { + totalFirstPicks++; + if (analysis.won) successfulFirstPicks++; + } + } + } + + const averageFightDuration = + fightCount > 0 ? totalFightDuration / fightCount : null; + const firstPickSuccessRate = + totalFirstPicks > 0 + ? { + successfulFirstPicks, + totalFirstPicks, + successRate: (successfulFirstPicks / totalFirstPicks) * 100, + } + : fightStats.firstPickFights > 0 + ? { + successfulFirstPicks: fightStats.firstPickWins, + totalFirstPicks: fightStats.firstPickFights, + successRate: fightStats.firstPickWinrate, + } + : null; + + return { + last10GamesPerformance: { + wins: last10Wins, + losses: last10Losses, + winrate: last10Winrate, + }, + bestDayOfWeek: bestDay, + averageFightDuration, + firstPickSuccessRate, + }; +} + +export type TeamQuickWinsServiceInterface = { + readonly getQuickWinsStats: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamQuickWinsService extends Context.Tag( + "@app/data/team/TeamQuickWinsService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + const fightStatsService = yield* TeamFightStatsService; + + function getQuickWinsStats( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const [fightStats, extendedData] = yield* Effect.all([ + fightStatsService.getTeamFightStats(teamId, dateRange), + shared.getExtendedTeamData(teamId, { + excludePush: true, + excludeClash: true, + includeDateInfo: true, + dateRange, + }), + ]); + + const result = processQuickWinsStats(extendedData, fightStats); + wideEvent.outcome = "success"; + wideEvent.last10_winrate = result.last10GamesPerformance.winrate; + yield* Metric.increment(quickWinsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(quickWinsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.quickWins.getQuickWinsStats") + : Effect.logInfo("team.quickWins.getQuickWinsStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(quickWinsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.quickWins.getQuickWinsStats") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const quickWinsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getQuickWinsStats(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getQuickWinsStats: (teamId: number, dateRange?: TeamDateRange) => + quickWinsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamQuickWinsServiceInterface; +}); + +export const TeamQuickWinsServiceLive = Layer.effect( + TeamQuickWinsService, + make +).pipe( + Layer.provide(TeamSharedDataServiceLive), + Layer.provide(TeamFightStatsServiceLive) +); diff --git a/src/data/team/role-stats-service.ts b/src/data/team/role-stats-service.ts new file mode 100644 index 000000000..256300994 --- /dev/null +++ b/src/data/team/role-stats-service.ts @@ -0,0 +1,911 @@ +import { determineRole } from "@/lib/player-table-data"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName } from "@/types/heroes"; +import { getTranslations } from "next-intl/server"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const roleStatsQuerySuccessTotal = Metric.counter( + "team.role_stats.query.success", + { description: "Total successful team role stats queries", incremental: true } +); + +const roleStatsQueryErrorTotal = Metric.counter("team.role_stats.query.error", { + description: "Total team role stats query failures", + incremental: true, +}); + +const roleStatsQueryDuration = Metric.histogram( + "team.role_stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team role stats query duration in milliseconds" +); + +export type RoleStats = { + role: "Tank" | "Damage" | "Support"; + totalPlaytime: number; + mapCount: number; + eliminations: number; + finalBlows: number; + deaths: number; + assists: number; + heroDamage: number; + damageTaken: number; + healing: number; + ultimatesEarned: number; + ultimatesUsed: number; + kd: number; + damagePer10Min: number; + healingPer10Min: number; + deathsPer10Min: number; + ultEfficiency: number; +}; + +export type RolePerformanceStats = { + Tank: RoleStats; + Damage: RoleStats; + Support: RoleStats; +}; + +export type RoleBalanceAnalysis = { + overall: string; + weakestRole: "Tank" | "Damage" | "Support" | null; + strongestRole: "Tank" | "Damage" | "Support" | null; + balanceScore: number; + insights: string[]; +}; + +export type RoleTrio = { + tank: string; + dps1: string; + dps2: string; + support1: string; + support2: string; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; +}; + +export type RoleWinrateByMap = { + mapName: string; + Tank: { wins: number; losses: number; winrate: number }; + Damage: { wins: number; losses: number; winrate: number }; + Support: { wins: number; losses: number; winrate: number }; +}; + +function createEmptyRoleStats(): RolePerformanceStats { + const emptyStats: RoleStats = { + role: "Tank", + totalPlaytime: 0, + mapCount: 0, + eliminations: 0, + finalBlows: 0, + deaths: 0, + assists: 0, + heroDamage: 0, + damageTaken: 0, + healing: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + kd: 0, + damagePer10Min: 0, + healingPer10Min: 0, + deathsPer10Min: 0, + ultEfficiency: 0, + }; + return { + Tank: { ...emptyStats, role: "Tank" }, + Damage: { ...emptyStats, role: "Damage" }, + Support: { ...emptyStats, role: "Support" }, + }; +} + +function processRolePerformanceStats( + sharedData: BaseTeamData +): RolePerformanceStats { + const { teamRosterSet, mapDataIds, allPlayerStats } = sharedData; + + if (mapDataIds.length === 0) return createEmptyRoleStats(); + + const roleAggregates: Record< + "Tank" | "Damage" | "Support", + { + eliminations: number; + finalBlows: number; + deaths: number; + assists: number; + heroDamage: number; + damageTaken: number; + healing: number; + ultimatesEarned: number; + ultimatesUsed: number; + totalPlaytime: number; + mapsPlayed: Set; + } + > = { + Tank: { + eliminations: 0, + finalBlows: 0, + deaths: 0, + assists: 0, + heroDamage: 0, + damageTaken: 0, + healing: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + totalPlaytime: 0, + mapsPlayed: new Set(), + }, + Damage: { + eliminations: 0, + finalBlows: 0, + deaths: 0, + assists: 0, + heroDamage: 0, + damageTaken: 0, + healing: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + totalPlaytime: 0, + mapsPlayed: new Set(), + }, + Support: { + eliminations: 0, + finalBlows: 0, + deaths: 0, + assists: 0, + heroDamage: 0, + damageTaken: 0, + healing: 0, + ultimatesEarned: 0, + ultimatesUsed: 0, + totalPlaytime: 0, + mapsPlayed: new Set(), + }, + }; + + for (const stat of allPlayerStats) { + if (!teamRosterSet.has(stat.player_name)) continue; + if (!stat.MapDataId) continue; + const role = determineRole(stat.player_hero as HeroName); + if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; + + const aggregate = roleAggregates[role]; + aggregate.eliminations += stat.eliminations; + aggregate.finalBlows += stat.final_blows; + aggregate.deaths += stat.deaths; + aggregate.assists += stat.offensive_assists; + aggregate.heroDamage += stat.hero_damage_dealt; + aggregate.damageTaken += stat.damage_taken; + aggregate.healing += stat.healing_dealt; + aggregate.ultimatesEarned += stat.ultimates_earned; + aggregate.ultimatesUsed += stat.ultimates_used; + aggregate.totalPlaytime += stat.hero_time_played; + aggregate.mapsPlayed.add(stat.MapDataId); + } + + function calculateRoleStats( + role: "Tank" | "Damage" | "Support", + aggregate: typeof roleAggregates.Tank + ): RoleStats { + const playtimeInMinutes = aggregate.totalPlaytime / 60; + return { + role, + totalPlaytime: aggregate.totalPlaytime, + mapCount: aggregate.mapsPlayed.size, + eliminations: aggregate.eliminations, + finalBlows: aggregate.finalBlows, + deaths: aggregate.deaths, + assists: aggregate.assists, + heroDamage: aggregate.heroDamage, + damageTaken: aggregate.damageTaken, + healing: aggregate.healing, + ultimatesEarned: aggregate.ultimatesEarned, + ultimatesUsed: aggregate.ultimatesUsed, + kd: aggregate.deaths > 0 ? aggregate.finalBlows / aggregate.deaths : 0, + damagePer10Min: + playtimeInMinutes > 0 + ? (aggregate.heroDamage / playtimeInMinutes) * 10 + : 0, + healingPer10Min: + playtimeInMinutes > 0 + ? (aggregate.healing / playtimeInMinutes) * 10 + : 0, + deathsPer10Min: + playtimeInMinutes > 0 ? (aggregate.deaths / playtimeInMinutes) * 10 : 0, + ultEfficiency: + aggregate.ultimatesUsed > 0 + ? aggregate.eliminations / aggregate.ultimatesUsed + : 0, + }; + } + + return { + Tank: calculateRoleStats("Tank", roleAggregates.Tank), + Damage: calculateRoleStats("Damage", roleAggregates.Damage), + Support: calculateRoleStats("Support", roleAggregates.Support), + }; +} + +function processBestRoleTrios(sharedData: BaseTeamData): RoleTrio[] { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + if (mapDataRecords.length === 0) return []; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type RosterCombo = { + tank: string; + dps1: string; + dps2: string; + support1: string; + support2: string; + key: string; + wins: number; + losses: number; + }; + const rosterCombos = new Map(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const playersByRole: Record<"Tank" | "Damage" | "Support", string[]> = { + Tank: [], + Damage: [], + Support: [], + }; + for (const stat of playersOnMap) { + const role = determineRole(stat.player_hero as HeroName); + if (role === "Tank" || role === "Damage" || role === "Support") { + if (!playersByRole[role].includes(stat.player_name)) { + playersByRole[role].push(stat.player_name); + } + } + } + + if ( + playersByRole.Tank.length !== 1 || + playersByRole.Damage.length !== 2 || + playersByRole.Support.length !== 2 + ) + continue; + + const tank = playersByRole.Tank[0]; + const [dps1, dps2] = playersByRole.Damage.sort(); + const [support1, support2] = playersByRole.Support.sort(); + const key = `${tank}|${dps1}|${dps2}|${support1}|${support2}`; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + + if (!rosterCombos.has(key)) { + rosterCombos.set(key, { + tank, + dps1, + dps2, + support1, + support2, + key, + wins: 0, + losses: 0, + }); + } + + const combo = rosterCombos.get(key)!; + if (isWin) combo.wins++; + else combo.losses++; + } + + return Array.from(rosterCombos.values()) + .filter((combo) => combo.wins + combo.losses >= 3) + .map((combo) => ({ + tank: combo.tank, + dps1: combo.dps1, + dps2: combo.dps2, + support1: combo.support1, + support2: combo.support2, + wins: combo.wins, + losses: combo.losses, + winrate: + combo.wins + combo.losses > 0 + ? (combo.wins / (combo.wins + combo.losses)) * 100 + : 0, + gamesPlayed: combo.wins + combo.losses, + })) + .sort((a, b) => b.winrate - a.winrate) + .slice(0, 5); +} + +function processRoleWinratesByMap( + sharedData: BaseTeamData +): RoleWinrateByMap[] { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + if (mapDataRecords.length === 0) return []; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type MapRoleStats = { + Tank: { wins: number; losses: number }; + Damage: { wins: number; losses: number }; + Support: { wins: number; losses: number }; + }; + const mapRoleData = new Map(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const mapName = mapDataRecord.name ?? "Unknown"; + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + + if (!mapRoleData.has(mapName)) { + mapRoleData.set(mapName, { + Tank: { wins: 0, losses: 0 }, + Damage: { wins: 0, losses: 0 }, + Support: { wins: 0, losses: 0 }, + }); + } + + const roleData = mapRoleData.get(mapName)!; + const rolesPlayed = new Set<"Tank" | "Damage" | "Support">(); + for (const stat of playersOnMap) { + const role = determineRole(stat.player_hero as HeroName); + if (role === "Tank" || role === "Damage" || role === "Support") + rolesPlayed.add(role); + } + + for (const role of rolesPlayed) { + if (isWin) roleData[role].wins++; + else roleData[role].losses++; + } + } + + const result: RoleWinrateByMap[] = Array.from(mapRoleData.entries()).map( + ([mapName, roleData]) => ({ + mapName, + Tank: { + wins: roleData.Tank.wins, + losses: roleData.Tank.losses, + winrate: + roleData.Tank.wins + roleData.Tank.losses > 0 + ? (roleData.Tank.wins / + (roleData.Tank.wins + roleData.Tank.losses)) * + 100 + : 0, + }, + Damage: { + wins: roleData.Damage.wins, + losses: roleData.Damage.losses, + winrate: + roleData.Damage.wins + roleData.Damage.losses > 0 + ? (roleData.Damage.wins / + (roleData.Damage.wins + roleData.Damage.losses)) * + 100 + : 0, + }, + Support: { + wins: roleData.Support.wins, + losses: roleData.Support.losses, + winrate: + roleData.Support.wins + roleData.Support.losses > 0 + ? (roleData.Support.wins / + (roleData.Support.wins + roleData.Support.losses)) * + 100 + : 0, + }, + }) + ); + + return result.sort((a, b) => { + const totalA = + a.Tank.wins + + a.Tank.losses + + a.Damage.wins + + a.Damage.losses + + a.Support.wins + + a.Support.losses; + const totalB = + b.Tank.wins + + b.Tank.losses + + b.Damage.wins + + b.Damage.losses + + b.Support.wins + + b.Support.losses; + return totalB - totalA; + }); +} + +export type TeamRoleStatsServiceInterface = { + readonly getRolePerformanceStats: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getRoleBalanceAnalysis: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getBestRoleTrios: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getRoleWinratesByMap: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamRoleStatsService extends Context.Tag( + "@app/data/team/TeamRoleStatsService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getRolePerformanceStats( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getBaseTeamData(teamId, { dateRange }); + const result = processRolePerformanceStats(data); + wideEvent.outcome = "success"; + wideEvent.tank_playtime = result.Tank.totalPlaytime; + yield* Metric.increment(roleStatsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(roleStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.roleStats.getRolePerformanceStats") + : Effect.logInfo("team.roleStats.getRolePerformanceStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(roleStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.roleStats.getRolePerformanceStats") + ); + } + + function getRoleBalanceAnalysis( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const t = yield* Effect.tryPromise({ + try: () => getTranslations("teamStatsPage.roleBalanceRadar"), + catch: (error) => + new TeamQueryError({ + operation: "get translations for role balance", + cause: error, + }), + }); + + const roleStats = yield* getRolePerformanceStats(teamId, dateRange); + + const roles: ("Tank" | "Damage" | "Support")[] = [ + "Tank", + "Damage", + "Support", + ]; + const totalPlaytime = roles.reduce( + (sum, role) => sum + roleStats[role].totalPlaytime, + 0 + ); + + if (totalPlaytime === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(roleStatsQuerySuccessTotal); + return { + overall: t("insufficientData"), + weakestRole: null, + strongestRole: null, + balanceScore: 0, + insights: [t("noData")], + }; + } + + const roleScores = roles.map((role) => { + const stats = roleStats[role]; + if (stats.totalPlaytime === 0) return { role, score: 0 }; + const kdScore = Math.min(stats.kd / 2, 1); + const survivalScore = Math.max(0, 1 - stats.deathsPer10Min / 2); + const ultScore = Math.min(stats.ultEfficiency / 3, 1); + const activityScore = Math.min(stats.totalPlaytime / 3600, 1); + const score = (kdScore + survivalScore + ultScore + activityScore) / 4; + return { role, score }; + }); + + roleScores.sort((a, b) => b.score - a.score); + const strongestRole = roleScores[0].role; + const weakestRole = roleScores[roleScores.length - 1].role; + + const scoreDiff = + roleScores[0].score - roleScores[roleScores.length - 1].score; + const balanceScore = Math.max(0, 1 - scoreDiff); + + let overall: string = t("balanced"); + if (balanceScore < 0.6) { + if (roleScores[0].score > 0.7) { + overall = t(`${strongestRole.toLowerCase()}Heavy`); + } + } + + const insights: string[] = []; + if (balanceScore >= 0.8) insights.push(t("excellentBalance")); + else if (balanceScore >= 0.6) insights.push(t("fairlyBalanced")); + else insights.push(t("considerStrengthening", { role: weakestRole })); + + roles.forEach((role) => { + const stats = roleStats[role]; + if (stats.kd < 1.0 && stats.totalPlaytime > 600) + insights.push(t("negativeKD", { role })); + if (stats.deathsPer10Min > 7 && stats.totalPlaytime > 600) + insights.push(t("dyingFrequently", { role })); + }); + + wideEvent.outcome = "success"; + wideEvent.balance_score = balanceScore; + yield* Metric.increment(roleStatsQuerySuccessTotal); + return { + overall, + weakestRole: balanceScore < 0.9 ? weakestRole : null, + strongestRole: balanceScore < 0.9 ? strongestRole : null, + balanceScore, + insights, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(roleStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.roleStats.getRoleBalanceAnalysis") + : Effect.logInfo("team.roleStats.getRoleBalanceAnalysis"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(roleStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.roleStats.getRoleBalanceAnalysis") + ); + } + + function getBestRoleTrios( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getBaseTeamData(teamId, { dateRange }); + const result = processBestRoleTrios(data); + wideEvent.outcome = "success"; + wideEvent.trio_count = result.length; + yield* Metric.increment(roleStatsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(roleStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.roleStats.getBestRoleTrios") + : Effect.logInfo("team.roleStats.getBestRoleTrios"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(roleStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.roleStats.getBestRoleTrios") + ); + } + + function getRoleWinratesByMap( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getBaseTeamData(teamId, { dateRange }); + const result = processRoleWinratesByMap(data); + wideEvent.outcome = "success"; + wideEvent.map_count = result.length; + yield* Metric.increment(roleStatsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(roleStatsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.roleStats.getRoleWinratesByMap") + : Effect.logInfo("team.roleStats.getRoleWinratesByMap"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(roleStatsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.roleStats.getRoleWinratesByMap") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const rolePerformanceCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getRolePerformanceStats(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const roleBalanceCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getRoleBalanceAnalysis(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const roleTriosCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getBestRoleTrios(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const roleWinratesByMapCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getRoleWinratesByMap(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getRolePerformanceStats: (teamId: number, dateRange?: TeamDateRange) => + rolePerformanceCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getRoleBalanceAnalysis: (teamId: number, dateRange?: TeamDateRange) => + roleBalanceCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getBestRoleTrios: (teamId: number, dateRange?: TeamDateRange) => + roleTriosCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getRoleWinratesByMap: (teamId: number, dateRange?: TeamDateRange) => + roleWinratesByMapCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamRoleStatsServiceInterface; +}); + +export const TeamRoleStatsServiceLive = Layer.effect( + TeamRoleStatsService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/stats-service.ts b/src/data/team/stats-service.ts new file mode 100644 index 000000000..a9a8d6019 --- /dev/null +++ b/src/data/team/stats-service.ts @@ -0,0 +1,852 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import type { PlayerStat } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const statsQuerySuccessTotal = Metric.counter("team.stats.query.success", { + description: "Total successful team stats queries", + incremental: true, +}); + +const statsQueryErrorTotal = Metric.counter("team.stats.query.error", { + description: "Total team stats query failures", + incremental: true, +}); + +const statsQueryDuration = Metric.histogram( + "team.stats.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team stats query duration in milliseconds" +); + +type RosterCombination = { + players: string[]; + wins: number; + losses: number; + winrate: number; +}; + +type MapWinrate = { + mapName: string; + totalWins: number; + totalLosses: number; + totalWinrate: number; + rosterVariants: RosterCombination[]; + bestRoster: string[] | null; + bestWinrate: number; +}; + +export type TeamWinrates = { + overallWins: number; + overallLosses: number; + overallWinrate: number; + byMap: Record; +}; + +export type TopMapByPlaytime = { + name: string; + playtime: number; +}; + +export type BestMapByWinrate = { + mapName: string; + playtime: number; + winrate: number; +}; + +function getRosterForMap( + mapDataId: number, + teamName: string, + allPlayerStats: { + player_name: string; + player_team: string; + MapDataId: number | null; + }[] +): string[] { + const roster = new Set(); + for (const stat of allPlayerStats) { + if (stat.MapDataId === mapDataId && stat.player_team === teamName) { + roster.add(stat.player_name); + } + } + return Array.from(roster).sort(); +} + +function findTeamNameForMap( + mapDataId: number, + allPlayerStats: { + player_name: string; + player_team: string; + MapDataId: number | null; + }[], + sortedPlayers: [string, number][] +): string | null { + for (const [playerName] of sortedPlayers) { + for (const stat of allPlayerStats) { + if (stat.MapDataId === mapDataId && stat.player_name === playerName) { + return stat.player_team; + } + } + } + return null; +} + +function processTeamWinrates(sharedData: BaseTeamData): TeamWinrates { + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + if (sharedData.teamRoster.length === 0 || mapDataRecords.length === 0) { + return { + overallWins: 0, + overallLosses: 0, + overallWinrate: 0, + byMap: {}, + }; + } + + const playerFrequencyMap = new Map(); + const mapPlayerSets = new Map>(); + + for (const stat of allPlayerStats) { + const mapDataId = stat.MapDataId; + if (!mapDataId) continue; + + if (!mapPlayerSets.has(mapDataId)) { + mapPlayerSets.set(mapDataId, new Set()); + } + + const playersInMap = mapPlayerSets.get(mapDataId)!; + if (!playersInMap.has(stat.player_name)) { + playersInMap.add(stat.player_name); + const currentCount = playerFrequencyMap.get(stat.player_name) ?? 0; + playerFrequencyMap.set(stat.player_name, currentCount + 1); + } + } + + const sortedPlayers = Array.from(playerFrequencyMap.entries()).sort( + (a, b) => b[1] - a[1] + ); + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const mapWinrateData = new Map(); + let overallWins = 0; + let overallLosses = 0; + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const mapName = mapDataRecord.name ?? "Unknown Map"; + + const teamName = findTeamNameForMap( + mapDataId, + allPlayerStats, + sortedPlayers + ); + + if (!teamName) continue; + + const roster = getRosterForMap(mapDataId, teamName, allPlayerStats); + + const allPlayersInTeamRoster = roster.every((player) => + teamRosterSet.has(player) + ); + + if (!allPlayersInTeamRoster) continue; + + const rosterKey = roster.join(","); + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + + if (isWin) { + overallWins++; + } else { + overallLosses++; + } + + if (!mapWinrateData.has(mapName)) { + mapWinrateData.set(mapName, { + mapName, + totalWins: 0, + totalLosses: 0, + totalWinrate: 0, + rosterVariants: [], + bestRoster: null, + bestWinrate: 0, + }); + } + + const currentMapData = mapWinrateData.get(mapName)!; + + if (isWin) { + currentMapData.totalWins++; + } else { + currentMapData.totalLosses++; + } + + let rosterVariant = currentMapData.rosterVariants.find( + (rv) => rv.players.join(",") === rosterKey + ); + + if (!rosterVariant) { + rosterVariant = { + players: roster, + wins: 0, + losses: 0, + winrate: 0, + }; + currentMapData.rosterVariants.push(rosterVariant); + } + + if (isWin) { + rosterVariant.wins++; + } else { + rosterVariant.losses++; + } + } + + for (const mapData of mapWinrateData.values()) { + const total = mapData.totalWins + mapData.totalLosses; + mapData.totalWinrate = total > 0 ? (mapData.totalWins / total) * 100 : 0; + + for (const variant of mapData.rosterVariants) { + const variantTotal = variant.wins + variant.losses; + variant.winrate = + variantTotal > 0 ? (variant.wins / variantTotal) * 100 : 0; + } + + mapData.rosterVariants.sort((a, b) => b.winrate - a.winrate); + + if (mapData.rosterVariants.length > 0) { + mapData.bestRoster = mapData.rosterVariants[0].players; + mapData.bestWinrate = mapData.rosterVariants[0].winrate; + } + } + + const byMap: Record = {}; + for (const [mn, data] of mapWinrateData.entries()) { + byMap[mn] = data; + } + + const overallTotal = overallWins + overallLosses; + const overallWinrate = + overallTotal > 0 ? (overallWins / overallTotal) * 100 : 0; + + return { overallWins, overallLosses, overallWinrate, byMap }; +} + +export type TeamStatsServiceInterface = { + readonly getTeamWinrates: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getTeamNameForRoster: ( + teamId: number, + mapDataId: number + ) => Effect.Effect; + + readonly filterTeamPlayerStats: ( + teamId: number, + mapDataId: number, + playerStats: PlayerStat[] + ) => Effect.Effect; + + readonly getTopMapsByPlaytime: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getTop5MapsByPlaytime: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getBestMapByWinrate: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getBlindSpotMap: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamStatsService extends Context.Tag( + "@app/data/team/TeamStatsService" +)() {} + +export const make = Effect.gen(function* () { + const sharedData = yield* TeamSharedDataService; + + function getTeamWinrates( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* sharedData.getBaseTeamData(teamId, { + excludePush: true, + dateRange, + }); + const result = processTeamWinrates(data); + wideEvent.overall_wins = result.overallWins; + wideEvent.overall_losses = result.overallLosses; + wideEvent.map_count = Object.keys(result.byMap).length; + wideEvent.outcome = "success"; + yield* Metric.increment(statsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(statsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.stats.getTeamWinrates") + : Effect.logInfo("team.stats.getTeamWinrates"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(statsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.stats.getTeamWinrates") + ); + } + + function getTeamNameForRoster( + teamId: number, + mapDataId: number + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { teamId, mapDataId }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("mapDataId", mapDataId); + const roster = yield* sharedData.getTeamRoster(teamId); + + if (roster.length === 0) { + wideEvent.outcome = "success"; + wideEvent.result = null; + return null; + } + + const playerStats = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: mapDataId }, + select: { player_name: true, player_team: true }, + distinct: ["player_name", "player_team"], + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch player stats for team name lookup", + cause: error, + }), + }); + + const teamCounts = new Map(); + for (const stat of playerStats) { + if (roster.includes(stat.player_name)) { + const currentCount = teamCounts.get(stat.player_team) ?? 0; + teamCounts.set(stat.player_team, currentCount + 1); + } + } + + let maxCount = 0; + let teamName: string | null = null; + for (const [team, count] of teamCounts.entries()) { + if (count > maxCount) { + maxCount = count; + teamName = team; + } + } + + wideEvent.outcome = "success"; + wideEvent.result = teamName; + yield* Metric.increment(statsQuerySuccessTotal); + return teamName; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(statsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.stats.getTeamNameForRoster") + : Effect.logInfo("team.stats.getTeamNameForRoster"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(statsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.stats.getTeamNameForRoster") + ); + } + + function filterTeamPlayerStats( + teamId: number, + mapDataId: number, + playerStats: PlayerStat[] + ): Effect.Effect { + return Effect.gen(function* () { + const teamName = yield* getTeamNameForRoster(teamId, mapDataId); + if (!teamName) return []; + return playerStats.filter((stat) => stat.player_team === teamName); + }); + } + + function getTopMapsByPlaytime( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const scrimWhereClause: Record = { + Team: { id: teamId }, + }; + if (dateRange) { + scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; + } + + const maps = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: scrimWhereClause }, + select: { + id: true, + name: true, + mapData: { select: { id: true } }, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch maps for playtime", + cause: error, + }), + }); + + if (maps.length === 0) { + wideEvent.outcome = "success"; + wideEvent.map_count = 0; + yield* Metric.increment(statsQuerySuccessTotal); + const _empty: TopMapByPlaytime[] = []; return _empty; + } + + const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); + + if (mapDataIds.length === 0) { + wideEvent.outcome = "success"; + wideEvent.map_count = 0; + yield* Metric.increment(statsQuerySuccessTotal); + const _empty: TopMapByPlaytime[] = []; return _empty; + } + + const mapDataIdToName = new Map(); + for (const map of maps) { + for (const md of map.mapData) { + mapDataIdToName.set(md.id, map.name ?? "Unknown Map"); + } + } + + const matchEnds = yield* Effect.tryPromise({ + try: () => + prisma.matchEnd.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { match_time: true, MapDataId: true }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch match ends for playtime", + cause: error, + }), + }); + + const playtimeByMapName = new Map(); + for (const matchEnd of matchEnds) { + const mdId = matchEnd.MapDataId; + if (!mdId) continue; + const mapName = mapDataIdToName.get(mdId) ?? "Unknown Map"; + const currentPlaytime = playtimeByMapName.get(mapName) ?? 0; + playtimeByMapName.set(mapName, currentPlaytime + matchEnd.match_time); + } + + const mapsWithPlaytime = Array.from(playtimeByMapName.entries()).map( + ([name, playtime]) => ({ name, playtime }) + ); + + const result = mapsWithPlaytime.sort((a, b) => b.playtime - a.playtime); + wideEvent.outcome = "success"; + wideEvent.map_count = result.length; + yield* Metric.increment(statsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(statsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.stats.getTopMapsByPlaytime") + : Effect.logInfo("team.stats.getTopMapsByPlaytime"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(statsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.stats.getTopMapsByPlaytime") + ); + } + + function getTop5MapsByPlaytime( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + return getTopMapsByPlaytime(teamId, dateRange).pipe( + Effect.map((maps) => maps.slice(0, 5)) + ); + } + + function getBestMapByWinrate( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const [winrates, topMaps] = yield* Effect.all([ + getTeamWinrates(teamId, dateRange), + getTopMapsByPlaytime(teamId, dateRange), + ]); + + const mapsWithStats = Object.keys(winrates.byMap).map((map) => ({ + mapName: map, + playtime: topMaps.find((m) => m.name === map)?.playtime ?? 0, + winrate: winrates.byMap[map].totalWinrate, + })); + + const result = mapsWithStats.sort((a, b) => { + if (b.winrate !== a.winrate) return b.winrate - a.winrate; + return b.playtime - a.playtime; + })[0]; + + wideEvent.outcome = "success"; + wideEvent.best_map = result?.mapName; + yield* Metric.increment(statsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(statsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.stats.getBestMapByWinrate") + : Effect.logInfo("team.stats.getBestMapByWinrate"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(statsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.stats.getBestMapByWinrate") + ); + } + + function getBlindSpotMap( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const [winrates, topMaps] = yield* Effect.all([ + getTeamWinrates(teamId, dateRange), + getTopMapsByPlaytime(teamId, dateRange), + ]); + + const mapsWithStats = Object.keys(winrates.byMap).map((map) => ({ + mapName: map, + playtime: topMaps.find((m) => m.name === map)?.playtime ?? 0, + winrate: winrates.byMap[map].totalWinrate, + })); + + const result = mapsWithStats.sort((a, b) => { + if (b.winrate !== a.winrate) return a.winrate - b.winrate; + return b.playtime - a.playtime; + })[0]; + + wideEvent.outcome = "success"; + wideEvent.blind_spot_map = result?.mapName; + yield* Metric.increment(statsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(statsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.stats.getBlindSpotMap") + : Effect.logInfo("team.stats.getBlindSpotMap"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(statsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.stats.getBlindSpotMap") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const winratesCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamWinrates(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const teamNameCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, mapDataIdStr] = key.split(":"); + return getTeamNameForRoster(Number(teamIdStr), Number(mapDataIdStr)).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const topMapsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTopMapsByPlaytime(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const top5MapsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTop5MapsByPlaytime(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const bestMapCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getBestMapByWinrate(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const blindSpotCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getBlindSpotMap(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamWinrates: (teamId: number, dateRange?: TeamDateRange) => + winratesCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getTeamNameForRoster: (teamId: number, mapDataId: number) => + teamNameCache + .get(`${teamId}:${mapDataId}`) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + filterTeamPlayerStats, + getTopMapsByPlaytime: (teamId: number, dateRange?: TeamDateRange) => + topMapsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getTop5MapsByPlaytime: (teamId: number, dateRange?: TeamDateRange) => + top5MapsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getBestMapByWinrate: (teamId: number, dateRange?: TeamDateRange) => + bestMapCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getBlindSpotMap: (teamId: number, dateRange?: TeamDateRange) => + blindSpotCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamStatsServiceInterface; +}); + +export const TeamStatsServiceLive = Layer.effect(TeamStatsService, make).pipe( + Layer.provide(TeamSharedDataServiceLive) +); diff --git a/src/data/team/trends-service.ts b/src/data/team/trends-service.ts new file mode 100644 index 000000000..5f11117db --- /dev/null +++ b/src/data/team/trends-service.ts @@ -0,0 +1,749 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const trendsQuerySuccessTotal = Metric.counter("team.trends.query.success", { + description: "Total successful team trends queries", + incremental: true, +}); + +const trendsQueryErrorTotal = Metric.counter("team.trends.query.error", { + description: "Total team trends query failures", + incremental: true, +}); + +const trendsQueryDuration = Metric.histogram( + "team.trends.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team trends query duration in milliseconds" +); + +export type WinrateDataPoint = { + date: Date; + winrate: number; + wins: number; + losses: number; + period: string; +}; + +export type RecentFormMatch = { + scrimId: number; + scrimName: string; + date: Date; + mapName: string; + result: "win" | "loss"; +}; + +export type RecentForm = { + last5: RecentFormMatch[]; + last10: RecentFormMatch[]; + last20: RecentFormMatch[]; + last5Winrate: number; + last10Winrate: number; + last20Winrate: number; +}; + +export type StreakInfo = { + currentStreak: { + type: "win" | "loss" | "none"; + count: number; + }; + longestWinStreak: { + count: number; + startDate: Date | null; + endDate: Date | null; + }; + longestLossStreak: { + count: number; + startDate: Date | null; + endDate: Date | null; + }; +}; + +type ProcessedMatchResult = { + scrimId: number; + scrimName: string; + date: Date; + mapName: string; + isWin: boolean; +}; + +function processTeamMatchResults( + sharedData: BaseTeamData, + mapDataRecordsWithScrim: { + id: number; + name: string | null; + Scrim?: { + id: number; + name: string; + date: Date; + } | null; + }[] +): ProcessedMatchResult[] { + const { + teamRosterSet, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + const matchResults: ProcessedMatchResult[] = []; + + for (const mapDataRecord of mapDataRecordsWithScrim) { + const mapDataId = mapDataRecord.id; + const scrim = mapDataRecord.Scrim; + + if (!scrim) continue; + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + + matchResults.push({ + scrimId: scrim.id, + scrimName: scrim.name, + date: scrim.date, + mapName: mapDataRecord.name ?? "Unknown", + isWin, + }); + } + + return matchResults; +} + +export type TeamTrendsServiceInterface = { + readonly getTeamMatchResults: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getWinrateOverTime: ( + teamId: number, + groupBy?: "week" | "month", + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getRecentForm: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getStreakInfo: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamTrendsService extends Context.Tag( + "@app/data/team/TeamTrendsService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamMatchResults( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const scrimWhereClause: Record = { + Team: { id: teamId }, + }; + if (dateRange) { + scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; + } + + const allMapDataRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: scrimWhereClause }, + select: { + id: true, + name: true, + Scrim: { select: { id: true, name: true, date: true } }, + }, + orderBy: { Scrim: { date: "desc" } }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch map records for match results", + cause: error, + }), + }); + + const mapDataRecords = allMapDataRecords.filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + return mapType !== $Enums.MapType.Push; + }); + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.match_count = 0; + yield* Metric.increment(trendsQuerySuccessTotal); + const _empty: ProcessedMatchResult[] = []; return _empty; + } + + const sharedData = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + dateRange, + }); + + const results = processTeamMatchResults(sharedData, mapDataRecords); + wideEvent.outcome = "success"; + wideEvent.match_count = results.length; + yield* Metric.increment(trendsQuerySuccessTotal); + return results; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(trendsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.trends.getTeamMatchResults") + : Effect.logInfo("team.trends.getTeamMatchResults"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(trendsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.trends.getTeamMatchResults") + ); + } + + function getWinrateOverTime( + teamId: number, + groupBy: "week" | "month" = "week", + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + groupBy, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + yield* Effect.annotateCurrentSpan("groupBy", groupBy); + const matchResults = yield* getTeamMatchResults(teamId, dateRange); + + if (matchResults.length === 0) { + wideEvent.outcome = "success"; + wideEvent.period_count = 0; + yield* Metric.increment(trendsQuerySuccessTotal); + const _empty: WinrateDataPoint[] = []; return _empty; + } + + type PeriodKey = string; + const periodData = new Map< + PeriodKey, + { date: Date; wins: number; losses: number } + >(); + + for (const result of matchResults) { + let periodKey: string; + let periodDate: Date; + + if (groupBy === "week") { + const date = new Date(result.date); + const day = date.getDay(); + const diff = date.getDate() - day + (day === 0 ? -6 : 1); + periodDate = new Date(date.setDate(diff)); + periodDate.setHours(0, 0, 0, 0); + periodKey = periodDate.toISOString(); + } else { + periodDate = new Date(result.date); + periodDate.setDate(1); + periodDate.setHours(0, 0, 0, 0); + periodKey = periodDate.toISOString(); + } + + if (!periodData.has(periodKey)) { + periodData.set(periodKey, { date: periodDate, wins: 0, losses: 0 }); + } + + const period = periodData.get(periodKey)!; + if (result.isWin) period.wins++; + else period.losses++; + } + + const dataPoints: WinrateDataPoint[] = Array.from(periodData.values()) + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map((period) => { + const total = period.wins + period.losses; + const winrate = total > 0 ? (period.wins / total) * 100 : 0; + + let periodLabel: string; + if (groupBy === "week") { + periodLabel = period.date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + } else { + periodLabel = period.date.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }); + } + + return { + date: period.date, + winrate, + wins: period.wins, + losses: period.losses, + period: periodLabel, + }; + }); + + wideEvent.outcome = "success"; + wideEvent.period_count = dataPoints.length; + yield* Metric.increment(trendsQuerySuccessTotal); + return dataPoints; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(trendsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.trends.getWinrateOverTime") + : Effect.logInfo("team.trends.getWinrateOverTime"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(trendsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.trends.getWinrateOverTime") + ); + } + + function getRecentForm( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const matchResults = yield* getTeamMatchResults(teamId, dateRange); + + if (matchResults.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(trendsQuerySuccessTotal); + return { + last5: [], + last10: [], + last20: [], + last5Winrate: 0, + last10Winrate: 0, + last20Winrate: 0, + }; + } + + const recentMatches: RecentFormMatch[] = matchResults + .slice(0, 20) + .map((result) => ({ + scrimId: result.scrimId, + scrimName: result.scrimName, + date: result.date, + mapName: result.mapName, + result: result.isWin ? ("win" as const) : ("loss" as const), + })); + + const last5 = recentMatches.slice(0, 5); + const last10 = recentMatches.slice(0, 10); + const last20 = recentMatches; + + function calculateWinrateForMatches(matches: RecentFormMatch[]): number { + if (matches.length === 0) return 0; + const wins = matches.filter((m) => m.result === "win").length; + return (wins / matches.length) * 100; + } + + const result: RecentForm = { + last5, + last10, + last20, + last5Winrate: calculateWinrateForMatches(last5), + last10Winrate: calculateWinrateForMatches(last10), + last20Winrate: calculateWinrateForMatches(last20), + }; + + wideEvent.outcome = "success"; + wideEvent.last5_winrate = result.last5Winrate; + yield* Metric.increment(trendsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(trendsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.trends.getRecentForm") + : Effect.logInfo("team.trends.getRecentForm"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(trendsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.trends.getRecentForm") + ); + } + + function getStreakInfo( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const matchResults = yield* getTeamMatchResults(teamId, dateRange); + + if (matchResults.length === 0) { + wideEvent.outcome = "success"; + yield* Metric.increment(trendsQuerySuccessTotal); + return { + currentStreak: { type: "none" as const, count: 0 }, + longestWinStreak: { count: 0, startDate: null, endDate: null }, + longestLossStreak: { count: 0, startDate: null, endDate: null }, + }; + } + + let currentStreak: StreakInfo["currentStreak"] = { + type: "none", + count: 0, + }; + if (matchResults.length > 0) { + const streakType = matchResults[0].isWin ? "win" : "loss"; + let count = 1; + for (let i = 1; i < matchResults.length; i++) { + if (matchResults[i].isWin === matchResults[0].isWin) count++; + else break; + } + currentStreak = { type: streakType, count }; + } + + let longestWinStreak = { + count: 0, + startDate: null as Date | null, + endDate: null as Date | null, + }; + let longestLossStreak = { + count: 0, + startDate: null as Date | null, + endDate: null as Date | null, + }; + + let currentWinCount = 0; + let currentWinStart: Date | null = null; + let currentLossCount = 0; + let currentLossStart: Date | null = null; + + for (let i = matchResults.length - 1; i >= 0; i--) { + const result = matchResults[i]; + if (result.isWin) { + if (currentWinCount === 0) currentWinStart = result.date; + currentWinCount++; + if (currentLossCount > 0) { + if (currentLossCount > longestLossStreak.count) { + longestLossStreak = { + count: currentLossCount, + startDate: currentLossStart, + endDate: matchResults[i + 1].date, + }; + } + currentLossCount = 0; + currentLossStart = null; + } + } else { + if (currentLossCount === 0) currentLossStart = result.date; + currentLossCount++; + if (currentWinCount > 0) { + if (currentWinCount > longestWinStreak.count) { + longestWinStreak = { + count: currentWinCount, + startDate: currentWinStart, + endDate: matchResults[i + 1].date, + }; + } + currentWinCount = 0; + currentWinStart = null; + } + } + } + + if (currentWinCount > longestWinStreak.count) { + longestWinStreak = { + count: currentWinCount, + startDate: currentWinStart, + endDate: matchResults[0].date, + }; + } + if (currentLossCount > longestLossStreak.count) { + longestLossStreak = { + count: currentLossCount, + startDate: currentLossStart, + endDate: matchResults[0].date, + }; + } + + const streakResult: StreakInfo = { + currentStreak, + longestWinStreak, + longestLossStreak, + }; + + wideEvent.outcome = "success"; + wideEvent.current_streak_type = currentStreak.type; + wideEvent.current_streak_count = currentStreak.count; + yield* Metric.increment(trendsQuerySuccessTotal); + return streakResult; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(trendsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.trends.getStreakInfo") + : Effect.logInfo("team.trends.getStreakInfo"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(trendsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.trends.getStreakInfo") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const matchResultsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamMatchResults(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const winrateOverTimeCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const parsed = JSON.parse(rest) as { + groupBy?: "week" | "month"; + dateRange?: TeamDateRange; + }; + const dr = + parsed.dateRange?.from + ? parsed.dateRange + : undefined; + return getWinrateOverTime(Number(teamIdStr), parsed.groupBy, dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const recentFormCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getRecentForm(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const streakInfoCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getStreakInfo(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamMatchResults: (teamId: number, dateRange?: TeamDateRange) => + matchResultsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getWinrateOverTime: ( + teamId: number, + groupBy?: "week" | "month", + dateRange?: TeamDateRange + ) => + winrateOverTimeCache + .get( + `${teamId}:${JSON.stringify({ groupBy, dateRange: dateRange ?? {} })}` + ) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getRecentForm: (teamId: number, dateRange?: TeamDateRange) => + recentFormCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getStreakInfo: (teamId: number, dateRange?: TeamDateRange) => + streakInfoCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamTrendsServiceInterface; +}); + +export const TeamTrendsServiceLive = Layer.effect(TeamTrendsService, make).pipe( + Layer.provide(TeamSharedDataServiceLive) +); diff --git a/src/data/team/ult-service.ts b/src/data/team/ult-service.ts new file mode 100644 index 000000000..f8e6ff2c6 --- /dev/null +++ b/src/data/team/ult-service.ts @@ -0,0 +1,835 @@ +import prisma from "@/lib/prisma"; +import type { Fight } from "@/lib/utils"; +import { + groupEventsIntoFights, + mercyRezToKillEvent, + ultimateStartToKillEvent, +} from "@/lib/utils"; +import type { RoleName, SubroleName } from "@/types/heroes"; +import { + getHeroRole, + ROLE_SUBROLES, + SUBROLE_DISPLAY_NAMES, +} from "@/types/heroes"; +import type { Kill } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { findTeamNameForMapInMemory } from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +import { + assignPlayersToSubroles, + type PlayerUltSummary, + type SubroleUltTiming, +} from "@/data/scrim/ult-helpers"; + +const ultQuerySuccessTotal = Metric.counter("team.ult.query.success", { + description: "Total successful team ult queries", + incremental: true, +}); + +const ultQueryErrorTotal = Metric.counter("team.ult.query.error", { + description: "Total team ult query failures", + incremental: true, +}); + +const ultQueryDuration = Metric.histogram( + "team.ult.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team ult query duration in milliseconds" +); + +export type ScenarioStats = { + fights: number; + wins: number; + losses: number; + winrate: number; +}; + +export type HeroUltImpact = { + hero: string; + totalFightsAnalyzed: number; + scenarios: { + uncontestedOurs: ScenarioStats; + uncontestedTheirs: ScenarioStats; + mirrorOursFirst: ScenarioStats; + mirrorTheirsFirst: ScenarioStats; + }; +}; + +export type UltImpactAnalysis = { + byHero: Record; + availableHeroes: string[]; +}; + +export type TeamUltRoleBreakdown = { + role: RoleName; + count: number; + percentage: number; + subroleTimings: SubroleUltTiming[]; +}; + +export type PlayerUltRanking = { + playerName: string; + primaryHero: string; + totalUltsUsed: number; + mapsPlayed: number; + ultsPerMap: number; + topFightOpeningHero: string | null; + fightOpeningCount: number; +}; + +export type FightOpeningHero = { hero: string; count: number }; + +export type TeamUltStats = { + totalUltsUsed: number; + totalUltsEarned: number; + totalMaps: number; + ultsPerMap: number; + avgChargeTime: number; + avgHoldTime: number; + fightInitiationRate: number; + fightInitiationCount: number; + totalFightsWithUlts: number; + topFightOpeningHeroes: FightOpeningHero[]; + roleBreakdown: TeamUltRoleBreakdown[]; + playerRankings: PlayerUltRanking[]; +}; + +function emptyScenario(): ScenarioStats { + return { fights: 0, wins: 0, losses: 0, winrate: 0 }; +} + +function finalizeScenario(s: ScenarioStats): ScenarioStats { + return { ...s, winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0 }; +} + +type ImpactFightEvent = { + event_type: string; + match_time: number; + attacker_team: string; + attacker_hero: string; + victim_team?: string; +}; + +type ImpactFight = { + events: ImpactFightEvent[]; + start: number; + end: number; +}; + +function processUltImpactAnalysis( + sharedData: ExtendedTeamData +): UltImpactAnalysis { + const { + teamRosterSet, + mapDataIds, + allPlayerStats, + allKills, + allRezzes, + allUltimates, + } = sharedData; + + if (mapDataIds.length === 0) return { byHero: {}, availableHeroes: [] }; + + const teamNameByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (teamName) teamNameByMapId.set(mapDataId, teamName); + } + + const killsByMap = new Map(); + const rezzesByMap = new Map(); + const ultsByMap = new Map(); + + for (const kill of allKills) { + if (kill.MapDataId) { + if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); + killsByMap.get(kill.MapDataId)!.push(kill); + } + } + for (const rez of allRezzes) { + if (rez.MapDataId) { + if (!rezzesByMap.has(rez.MapDataId)) rezzesByMap.set(rez.MapDataId, []); + rezzesByMap.get(rez.MapDataId)!.push(rez); + } + } + for (const ult of allUltimates) { + if (ult.MapDataId) { + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); + ultsByMap.get(ult.MapDataId)!.push(ult); + } + } + + const heroStats = new Map< + string, + { + uncontestedOurs: ScenarioStats; + uncontestedTheirs: ScenarioStats; + mirrorOursFirst: ScenarioStats; + mirrorTheirsFirst: ScenarioStats; + totalFightsAnalyzed: number; + } + >(); + + function getOrCreateHero(hero: string) { + if (!heroStats.has(hero)) + heroStats.set(hero, { + uncontestedOurs: emptyScenario(), + uncontestedTheirs: emptyScenario(), + mirrorOursFirst: emptyScenario(), + mirrorTheirsFirst: emptyScenario(), + totalFightsAnalyzed: 0, + }); + return heroStats.get(hero)!; + } + + function incrementScenario(scenario: ScenarioStats, won: boolean) { + scenario.fights++; + if (won) scenario.wins++; + else scenario.losses++; + } + + for (const mapDataId of mapDataIds) { + const ourTeamName = teamNameByMapId.get(mapDataId); + if (!ourTeamName) continue; + + const kills = killsByMap.get(mapDataId) ?? []; + const rezzes = rezzesByMap.get(mapDataId) ?? []; + const ults = ultsByMap.get(mapDataId) ?? []; + if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) + continue; + + const events: ImpactFightEvent[] = [ + ...kills.map((k) => ({ + event_type: k.event_type, + match_time: k.match_time, + attacker_team: k.attacker_team, + attacker_hero: k.attacker_hero, + victim_team: k.victim_team, + })), + ...rezzes.map((rez) => ({ + event_type: "mercy_rez" as const, + match_time: rez.match_time, + attacker_team: rez.resurrecter_team, + attacker_hero: rez.resurrecter_hero, + victim_team: rez.resurrectee_team, + })), + ...ults.map((ult) => ({ + event_type: "ultimate_start" as const, + match_time: ult.match_time, + attacker_team: ult.player_team, + attacker_hero: ult.player_hero, + })), + ]; + events.sort((a, b) => a.match_time - b.match_time); + + const fights: ImpactFight[] = []; + let currentFight: ImpactFight | null = null; + for (const event of events) { + if (!currentFight || event.match_time - currentFight.end > 15) { + currentFight = { + events: [event], + start: event.match_time, + end: event.match_time, + }; + fights.push(currentFight); + } else { + currentFight.events.push(event); + currentFight.end = event.match_time; + } + } + + for (const fight of fights) { + let ourKills = 0, + enemyKills = 0; + for (const event of fight.events) { + if (event.event_type === "mercy_rez") { + if (event.victim_team === ourTeamName) + enemyKills = Math.max(0, enemyKills - 1); + else ourKills = Math.max(0, ourKills - 1); + } else if (event.event_type === "kill") { + if (event.attacker_team === ourTeamName) ourKills++; + else enemyKills++; + } + } + const won = ourKills > enemyKills; + + const ultEvents = fight.events.filter( + (e) => e.event_type === "ultimate_start" + ); + const heroUltMap = new Map< + string, + { ourUses: ImpactFightEvent[]; theirUses: ImpactFightEvent[] } + >(); + for (const ult of ultEvents) { + const hero = ult.attacker_hero; + if (!heroUltMap.has(hero)) + heroUltMap.set(hero, { ourUses: [], theirUses: [] }); + const entry = heroUltMap.get(hero)!; + if (ult.attacker_team === ourTeamName) entry.ourUses.push(ult); + else entry.theirUses.push(ult); + } + + for (const [hero, { ourUses, theirUses }] of heroUltMap) { + const stats = getOrCreateHero(hero); + stats.totalFightsAnalyzed++; + if (ourUses.length > 0 && theirUses.length === 0) + incrementScenario(stats.uncontestedOurs, won); + else if (ourUses.length === 0 && theirUses.length > 0) + incrementScenario(stats.uncontestedTheirs, won); + else if (ourUses.length > 0 && theirUses.length > 0) { + const ourEarliest = Math.min(...ourUses.map((u) => u.match_time)); + const theirEarliest = Math.min(...theirUses.map((u) => u.match_time)); + if (ourEarliest <= theirEarliest) + incrementScenario(stats.mirrorOursFirst, won); + else incrementScenario(stats.mirrorTheirsFirst, won); + } + } + } + } + + const byHero: Record = {}; + for (const [hero, stats] of heroStats) { + if (stats.totalFightsAnalyzed < 1) continue; + byHero[hero] = { + hero, + totalFightsAnalyzed: stats.totalFightsAnalyzed, + scenarios: { + uncontestedOurs: finalizeScenario(stats.uncontestedOurs), + uncontestedTheirs: finalizeScenario(stats.uncontestedTheirs), + mirrorOursFirst: finalizeScenario(stats.mirrorOursFirst), + mirrorTheirsFirst: finalizeScenario(stats.mirrorTheirsFirst), + }, + }; + } + const availableHeroes = Object.values(byHero) + .sort((a, b) => b.totalFightsAnalyzed - a.totalFightsAnalyzed) + .map((h) => h.hero); + return { byHero, availableHeroes }; +} + +function emptyTeamUltStats(): TeamUltStats { + return { + totalUltsUsed: 0, + totalUltsEarned: 0, + totalMaps: 0, + ultsPerMap: 0, + avgChargeTime: 0, + avgHoldTime: 0, + fightInitiationRate: 0, + fightInitiationCount: 0, + totalFightsWithUlts: 0, + topFightOpeningHeroes: [], + roleBreakdown: [], + playerRankings: [], + }; +} + +function primaryHeroForPlayer(entry: PlayerUltSummary): string { + let bestHero = ""; + let bestCount = 0; + for (const [hero, count] of entry.heroCountMap) { + if (count > bestCount) { + bestCount = count; + bestHero = hero; + } + } + return bestHero; +} + +type UltStartRecord = { + player_team: string; + player_name: string; + player_hero: string; + match_time: number; + MapDataId: number | null; +}; +type SubroleTimingAccum = { + count: number; + initiation: number; + midfight: number; + late: number; +}; + +function createSubroleTimingMap() { + const roles: RoleName[] = ["Tank", "Damage", "Support"]; + const map = new Map>(); + for (const role of roles) { + const inner = new Map(); + for (const sr of ROLE_SUBROLES[role]) + inner.set(sr, { count: 0, initiation: 0, midfight: 0, late: 0 }); + map.set(role, inner); + } + return map; +} + +function extractTimings( + timingMap: Map>, + role: RoleName +): SubroleUltTiming[] { + const result: SubroleUltTiming[] = []; + const inner = timingMap.get(role)!; + for (const sr of ROLE_SUBROLES[role]) { + const accum = inner.get(sr)!; + if (accum.count > 0) + result.push({ + subrole: SUBROLE_DISPLAY_NAMES[sr], + count: accum.count, + initiation: accum.initiation, + midfight: accum.midfight, + late: accum.late, + }); + } + return result; +} + +type CalculatedStatRow = { playerName: string; stat: string; value: number }; + +function processTeamUltStats( + sharedData: ExtendedTeamData, + calculatedStats: CalculatedStatRow[] +): TeamUltStats { + const { + teamRosterSet, + mapDataIds, + allPlayerStats, + allKills, + allRezzes, + allUltimates, + } = sharedData; + if (mapDataIds.length === 0) return emptyTeamUltStats(); + + const roles: RoleName[] = ["Tank", "Damage", "Support"]; + + const teamNameByMapId = new Map(); + for (const mapDataId of mapDataIds) { + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (teamName) teamNameByMapId.set(mapDataId, teamName); + } + + let totalUltsEarned = 0; + for (const stat of allPlayerStats) { + if (!stat.MapDataId) continue; + const ourTeam = teamNameByMapId.get(stat.MapDataId); + if (ourTeam && stat.player_team === ourTeam) + totalUltsEarned += stat.ultimates_earned; + } + + const killsByMap = new Map(); + const ultsByMap = new Map(); + + for (const kill of allKills) { + if (kill.MapDataId) { + if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); + killsByMap.get(kill.MapDataId)!.push(kill); + } + } + for (const rez of allRezzes) { + if (rez.MapDataId) { + if (!killsByMap.has(rez.MapDataId)) killsByMap.set(rez.MapDataId, []); + killsByMap.get(rez.MapDataId)!.push(mercyRezToKillEvent(rez)); + } + } + for (const ult of allUltimates) { + if (ult.MapDataId) { + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); + ultsByMap.get(ult.MapDataId)!.push(ult); + } + } + + const ourPlayerUltCounts = new Map(); + const playerMapSets = new Map>(); + const playerFightOpenings = new Map>(); + + function trackPlayerUlt(playerName: string, hero: string, mapDataId: number) { + let entry = ourPlayerUltCounts.get(playerName); + if (!entry) { + entry = { heroCountMap: new Map(), totalCount: 0 }; + ourPlayerUltCounts.set(playerName, entry); + } + entry.totalCount++; + entry.heroCountMap.set(hero, (entry.heroCountMap.get(hero) ?? 0) + 1); + if (!playerMapSets.has(playerName)) + playerMapSets.set(playerName, new Set()); + playerMapSets.get(playerName)!.add(mapDataId); + } + + const ultsByRole: Record = { + Tank: 0, + Damage: 0, + Support: 0, + }; + let totalOurUltsUsed = 0; + const fightOpeningHeroCounts = new Map(); + let fightInitiationCount = 0; + let totalFightsWithUlts = 0; + const subroleTimingByRole = createSubroleTimingMap(); + + for (const [mapId, ults] of ultsByMap) { + const ourTeamName = teamNameByMapId.get(mapId); + if (!ourTeamName) continue; + for (const ult of ults) { + if (ult.player_team === ourTeamName) + trackPlayerUlt(ult.player_name, ult.player_hero, mapId); + } + } + + const ourSubroleAssignment = assignPlayersToSubroles(ourPlayerUltCounts); + const ourPlayerSubrole = new Map(); + for (const [sr, candidate] of ourSubroleAssignment) + ourPlayerSubrole.set(candidate.playerName, sr); + + for (const mapId of mapDataIds) { + const ourTeamName = teamNameByMapId.get(mapId); + if (!ourTeamName) continue; + + const mapUlts = ultsByMap.get(mapId) ?? []; + const mapKills = killsByMap.get(mapId) ?? []; + + for (const ult of mapUlts) { + if (ult.player_team === ourTeamName) { + const role = getHeroRole(ult.player_hero); + ultsByRole[role]++; + totalOurUltsUsed++; + } + } + + if (mapKills.length === 0 && mapUlts.length === 0) continue; + + const fightEvents: Kill[] = [ + ...mapKills, + ...mapUlts.map(ultimateStartToKillEvent), + ]; + fightEvents.sort((a, b) => a.match_time - b.match_time); + const fights: Fight[] = groupEventsIntoFights(fightEvents); + + for (const fight of fights) { + const fightUlts = mapUlts.filter( + (u) => u.match_time >= fight.start && u.match_time <= fight.end + 15 + ); + if (fightUlts.length === 0) continue; + + totalFightsWithUlts++; + const opener = fightUlts[0]; + if (opener.player_team === ourTeamName) { + fightInitiationCount++; + fightOpeningHeroCounts.set( + opener.player_hero, + (fightOpeningHeroCounts.get(opener.player_hero) ?? 0) + 1 + ); + if (!playerFightOpenings.has(opener.player_name)) + playerFightOpenings.set(opener.player_name, new Map()); + const heroMap = playerFightOpenings.get(opener.player_name)!; + heroMap.set( + opener.player_hero, + (heroMap.get(opener.player_hero) ?? 0) + 1 + ); + } + + const fightDuration = fight.end + 15 - fight.start; + const thirdDuration = fightDuration / 3; + for (const ult of fightUlts) { + if (ult.player_team !== ourTeamName) continue; + const subrole = ourPlayerSubrole.get(ult.player_name); + if (!subrole) continue; + const role = getHeroRole(ult.player_hero); + const accum = subroleTimingByRole.get(role)?.get(subrole); + if (!accum) continue; + accum.count++; + const elapsed = ult.match_time - fight.start; + if (fightDuration <= 0 || elapsed < thirdDuration) accum.initiation++; + else if (elapsed < thirdDuration * 2) accum.midfight++; + else accum.late++; + } + } + } + + const roleBreakdown: TeamUltRoleBreakdown[] = roles.map((role) => ({ + role, + count: ultsByRole[role], + percentage: + totalOurUltsUsed > 0 ? (ultsByRole[role] / totalOurUltsUsed) * 100 : 0, + subroleTimings: extractTimings(subroleTimingByRole, role), + })); + + const topFightOpeningHeroes: FightOpeningHero[] = Array.from( + fightOpeningHeroCounts.entries() + ) + .map(([hero, count]) => ({ hero, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const playerRankings: PlayerUltRanking[] = []; + for (const [playerName, entry] of ourPlayerUltCounts) { + const hero = primaryHeroForPlayer(entry); + const mapsPlayed = playerMapSets.get(playerName)?.size ?? 0; + let topFightOpeningHero: string | null = null; + let fightOpeningCountVal = 0; + const openings = playerFightOpenings.get(playerName); + if (openings) { + for (const [h, count] of openings) { + if (count > fightOpeningCountVal) { + fightOpeningCountVal = count; + topFightOpeningHero = h; + } + } + } + playerRankings.push({ + playerName, + primaryHero: hero, + totalUltsUsed: entry.totalCount, + mapsPlayed, + ultsPerMap: mapsPlayed > 0 ? entry.totalCount / mapsPlayed : 0, + topFightOpeningHero, + fightOpeningCount: fightOpeningCountVal, + }); + } + playerRankings.sort((a, b) => b.totalUltsUsed - a.totalUltsUsed); + + const chargeTimeValues: number[] = []; + const holdTimeValues: number[] = []; + for (const cs of calculatedStats) { + if (!teamRosterSet.has(cs.playerName) || cs.value <= 0) continue; + if (cs.stat === "AVERAGE_ULT_CHARGE_TIME") chargeTimeValues.push(cs.value); + else if (cs.stat === "AVERAGE_TIME_TO_USE_ULT") + holdTimeValues.push(cs.value); + } + const avgChargeTime = + chargeTimeValues.length > 0 + ? chargeTimeValues.reduce((a, b) => a + b, 0) / chargeTimeValues.length + : 0; + const avgHoldTime = + holdTimeValues.length > 0 + ? holdTimeValues.reduce((a, b) => a + b, 0) / holdTimeValues.length + : 0; + const totalMaps = mapDataIds.length; + + return { + totalUltsUsed: totalOurUltsUsed, + totalUltsEarned, + totalMaps, + ultsPerMap: totalMaps > 0 ? totalOurUltsUsed / totalMaps : 0, + avgChargeTime, + avgHoldTime, + fightInitiationRate: + totalFightsWithUlts > 0 + ? (fightInitiationCount / totalFightsWithUlts) * 100 + : 0, + fightInitiationCount, + totalFightsWithUlts, + topFightOpeningHeroes, + roleBreakdown, + playerRankings, + }; +} + +export type TeamUltServiceInterface = { + readonly getTeamUltImpact: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getTeamUltStats: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamUltService extends Context.Tag( + "@app/data/team/TeamUltService" +)() {} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getTeamUltImpact( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getExtendedTeamData(teamId, { dateRange }); + const result = processUltImpactAnalysis(data); + wideEvent.outcome = "success"; + wideEvent.hero_count = result.availableHeroes.length; + yield* Metric.increment(ultQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(ultQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.ult.getTeamUltImpact") + : Effect.logInfo("team.ult.getTeamUltImpact"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(ultQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.ult.getTeamUltImpact") + ); + } + + function getTeamUltStats( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const data = yield* shared.getExtendedTeamData(teamId, { dateRange }); + + if (data.mapDataIds.length === 0) { + wideEvent.outcome = "success"; + wideEvent.total_ults = 0; + yield* Metric.increment(ultQuerySuccessTotal); + return emptyTeamUltStats(); + } + + const calculatedStats = yield* Effect.tryPromise({ + try: () => + prisma.calculatedStat.findMany({ + where: { + MapDataId: { in: data.mapDataIds }, + stat: { + in: ["AVERAGE_ULT_CHARGE_TIME", "AVERAGE_TIME_TO_USE_ULT"], + }, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch calculated stats for ult stats", + cause: error, + }), + }); + + const result = processTeamUltStats(data, calculatedStats); + wideEvent.outcome = "success"; + wideEvent.total_ults = result.totalUltsUsed; + yield* Metric.increment(ultQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(ultQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.ult.getTeamUltStats") + : Effect.logInfo("team.ult.getTeamUltStats"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(ultQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.ult.getTeamUltStats") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const ultImpactCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamUltImpact(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const ultStatsCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getTeamUltStats(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getTeamUltImpact: (teamId: number, dateRange?: TeamDateRange) => + ultImpactCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getTeamUltStats: (teamId: number, dateRange?: TeamDateRange) => + ultStatsCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamUltServiceInterface; +}); + +export const TeamUltServiceLive = Layer.effect(TeamUltService, make).pipe( + Layer.provide(TeamSharedDataServiceLive) +); From f19139569ab6f95fd8279821b6589836bb12b41a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 15:59:34 -0400 Subject: [PATCH 14/42] Add DataLayerLive composing all service layers Single entry point that merges all 41 service layers. Effect's layer memoization ensures EffectObservabilityLive initializes once regardless of how many services depend on it. --- src/data/layer.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/data/layer.ts diff --git a/src/data/layer.ts b/src/data/layer.ts new file mode 100644 index 000000000..4f0ffde12 --- /dev/null +++ b/src/data/layer.ts @@ -0,0 +1,94 @@ +import { Layer } from "effect"; + +import { DataLabelingServiceLive } from "./admin/data-labeling-service"; +import { ComparisonAggregationServiceLive } from "./comparison/aggregation-service"; +import { ComparisonTrendsServiceLive } from "./comparison/trends-service"; +import { HeroServiceLive } from "./hero/service"; +import { HeroBanIntelligenceServiceLive } from "./intelligence/hero-ban-service"; +import { MapIntelligenceServiceLive } from "./intelligence/map-service"; +import { HeatmapServiceLive } from "./map/heatmap/service"; +import { MapGroupServiceLive } from "./map/group-service"; +import { KillfeedCalibrationServiceLive } from "./map/killfeed/calibration"; +import { KillfeedServiceLive } from "./map/killfeed/service"; +import { ReplayServiceLive } from "./map/replay/service"; +import { RotationDeathServiceLive } from "./map/rotation-death-service"; +import { TempoServiceLive } from "./map/tempo-service"; +import { IntelligenceServiceLive } from "./player/intelligence-service"; +import { PlayerServiceLive } from "./player/player-service"; +import { ScoutingAnalyticsServiceLive } from "./player/scouting-analytics-service"; +import { ScoutingServiceLive as PlayerScoutingServiceLive } from "./player/scouting-service"; +import { TargetsServiceLive } from "./player/targets-service"; +import { OpponentStrengthServiceLive } from "./scouting/opponent-strength-service"; +import { ScoutingServiceLive } from "./scouting/scouting-service"; +import { ScrimAbilityTimingServiceLive } from "./scrim/ability-timing-service"; +import { ScrimOpponentServiceLive } from "./scrim/opponent-service"; +import { ScrimOverviewServiceLive } from "./scrim/overview-service"; +import { ScrimServiceLive } from "./scrim/scrim-service"; +import { TeamAbilityImpactServiceLive } from "./team/ability-impact-service"; +import { TeamBanImpactServiceLive } from "./team/ban-impact-service"; +import { TeamComparisonServiceLive } from "./team/comparison-service"; +import { TeamFightStatsServiceLive } from "./team/fight-stats-service"; +import { TeamHeroPoolServiceLive } from "./team/hero-pool-service"; +import { TeamHeroSwapServiceLive } from "./team/hero-swap-service"; +import { TeamMapModeServiceLive } from "./team/map-mode-service"; +import { TeamMatchupServiceLive } from "./team/matchup-service"; +import { TeamQuickWinsServiceLive } from "./team/quick-wins-service"; +import { TeamRoleStatsServiceLive } from "./team/role-stats-service"; +import { TeamSharedDataServiceLive } from "./team/shared-data-service"; +import { TeamStatsServiceLive } from "./team/stats-service"; +import { TeamTrendsServiceLive } from "./team/trends-service"; +import { TeamUltServiceLive } from "./team/ult-service"; +import { BroadcastServiceLive } from "./tournament/broadcast-service"; +import { TournamentServiceLive } from "./tournament/tournament-service"; +import { UserServiceLive } from "./user/service"; + +export const DataLayerLive = Layer.mergeAll( + UserServiceLive, + DataLabelingServiceLive, + HeroServiceLive, + TournamentServiceLive, + BroadcastServiceLive, + + ComparisonAggregationServiceLive, + ComparisonTrendsServiceLive, + + ScoutingServiceLive, + OpponentStrengthServiceLive, + + ScrimServiceLive, + ScrimOverviewServiceLive, + ScrimOpponentServiceLive, + ScrimAbilityTimingServiceLive, + + PlayerServiceLive, + IntelligenceServiceLive, + PlayerScoutingServiceLive, + ScoutingAnalyticsServiceLive, + TargetsServiceLive, + + HeatmapServiceLive, + KillfeedServiceLive, + KillfeedCalibrationServiceLive, + ReplayServiceLive, + TempoServiceLive, + RotationDeathServiceLive, + MapGroupServiceLive, + + HeroBanIntelligenceServiceLive, + MapIntelligenceServiceLive, + + TeamSharedDataServiceLive, + TeamStatsServiceLive, + TeamTrendsServiceLive, + TeamFightStatsServiceLive, + TeamRoleStatsServiceLive, + TeamHeroPoolServiceLive, + TeamHeroSwapServiceLive, + TeamMapModeServiceLive, + TeamQuickWinsServiceLive, + TeamUltServiceLive, + TeamBanImpactServiceLive, + TeamAbilityImpactServiceLive, + TeamComparisonServiceLive, + TeamMatchupServiceLive +); From c340360e4d801524e6a2a7e19250b71da05d7ed2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:27:49 -0400 Subject: [PATCH 15/42] Add AppRuntime singleton for native Effect consumers Single ManagedRuntime created from DataLayerLive. All consumer files will use AppRuntime.runPromise to execute Effect programs at the RSC/API boundary. --- src/data/runtime.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/data/runtime.ts diff --git a/src/data/runtime.ts b/src/data/runtime.ts new file mode 100644 index 000000000..3ffcc5c5b --- /dev/null +++ b/src/data/runtime.ts @@ -0,0 +1,4 @@ +import { ManagedRuntime } from "effect"; +import { DataLayerLive } from "./layer"; + +export const AppRuntime = ManagedRuntime.make(DataLayerLive); From 4097901c4396efff867797fd2b8b69f4d46367bf Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:30:39 -0400 Subject: [PATCH 16/42] Migrate user-dto consumers to native Effect Switch 89 files from @/data/user-dto to UserService via AppRuntime. Each await getUser(email) becomes AppRuntime.runPromise(UserService.pipe(Effect.flatMap(...))). --- src/app/[team]/compare/page.tsx | 8 +++-- src/app/[team]/map-groups/page.tsx | 8 +++-- .../scrim/[scrimId]/map/[mapId]/page.tsx | 8 +++-- .../map/[mapId]/player/[playerId]/page.tsx | 8 +++-- src/app/api/(email)/send-team-invite/route.ts | 12 ++++++-- src/app/api/admin/audit-logs/route.ts | 8 +++-- src/app/api/admin/impersonate-user/route.ts | 8 +++-- .../[id]/anchors/[anchorId]/route.ts | 12 ++++++-- .../map-calibration/[id]/anchors/route.ts | 12 ++++++-- .../api/admin/map-calibration/[id]/route.ts | 16 +++++++--- .../compute-transform/route.ts | 8 +++-- src/app/api/admin/map-calibration/route.ts | 12 ++++++-- .../map-calibration/upload/presign/route.ts | 8 +++-- .../api/admin/map-calibration/upload/route.ts | 8 +++-- src/app/api/admin/user-search/route.ts | 8 +++-- src/app/api/chat/conversations/[id]/route.ts | 8 +++-- src/app/api/chat/conversations/route.ts | 12 ++++++-- src/app/api/chat/route.ts | 8 +++-- src/app/api/compare/groups/[id]/route.ts | 8 +++-- src/app/api/compare/groups/route.ts | 12 ++++++-- src/app/api/compare/map-groups/[id]/route.ts | 12 ++++++-- src/app/api/compare/map-groups/route.ts | 12 ++++++-- src/app/api/compare/maps/route.ts | 8 +++-- src/app/api/compare/players/route.ts | 8 +++-- src/app/api/compare/stats/route.ts | 8 +++-- src/app/api/data-labeling/matches/route.ts | 8 +++-- src/app/api/data-labeling/save-comp/route.ts | 8 +++-- src/app/api/notifications/mark-read/route.ts | 8 +++-- src/app/api/notifications/route.ts | 20 +++++++++---- .../api/reporting/submit-bug-report/route.ts | 8 +++-- src/app/api/scrim/create-scrim/route.ts | 12 ++++++-- src/app/api/scrim/edit-note/route.ts | 8 +++-- src/app/api/scrim/get-scrims/route.ts | 8 +++-- src/app/api/team/avatar-upload/route.ts | 8 +++-- src/app/api/team/create-team/route.ts | 8 +++-- src/app/api/team/demote-user/route.ts | 8 +++-- .../api/team/get-teams-with-perms/route.ts | 8 +++-- src/app/api/team/get-teams/route.ts | 8 +++-- src/app/api/team/promote-user/route.ts | 8 +++-- src/app/api/team/remove-team/route.ts | 8 +++-- .../api/team/remove-user-from-team/route.ts | 8 +++-- src/app/api/team/targets/[id]/route.ts | 12 ++++++-- src/app/api/team/targets/route.ts | 12 ++++++-- src/app/api/team/transfer-ownership/route.ts | 12 ++++++-- src/app/api/team/update-avatar/route.ts | 8 +++-- src/app/api/tournament/create/route.ts | 8 +++-- src/app/api/user/app-settings/route.ts | 16 +++++++--- src/app/api/user/avatar-upload/route.ts | 8 +++-- src/app/api/user/banner-upload/route.ts | 8 +++-- src/app/api/user/delete-account/route.ts | 8 +++-- src/app/api/vod/route.ts | 8 +++-- src/app/chat/[conversationId]/page.tsx | 8 +++-- src/app/dashboard/page.tsx | 10 +++++-- src/app/layout.tsx | 10 +++++-- src/app/notifications/page.tsx | 8 +++-- src/app/pricing/page.tsx | 8 +++-- src/app/profile/page.tsx | 8 +++-- src/app/reports/[id]/page.tsx | 8 +++-- src/app/reports/page.tsx | 8 +++-- src/app/settings/accounts/page.tsx | 8 +++-- src/app/settings/admin/analytics/layout.tsx | 8 +++-- src/app/settings/admin/analytics/page.tsx | 8 +++-- src/app/settings/admin/audit-logs/layout.tsx | 8 +++-- src/app/settings/admin/audit-logs/page.tsx | 8 +++-- .../admin/impersonate-user/layout.tsx | 8 +++-- .../settings/admin/impersonate-user/page.tsx | 8 +++-- src/app/settings/admin/page.tsx | 8 +++-- src/app/settings/billing/page.tsx | 8 +++-- src/app/settings/layout.tsx | 8 +++-- src/app/settings/page.tsx | 14 +++++++-- src/app/stats/hero/[heroName]/page.tsx | 8 +++-- src/app/stats/team/[teamId]/page.tsx | 8 +++-- src/app/stats/team/page.tsx | 8 +++-- src/app/team/[teamId]/page.tsx | 8 +++-- src/app/team/[teamId]/targets/page.tsx | 8 +++-- src/app/team/page.tsx | 10 +++++-- src/components/dashboard-layout.tsx | 8 +++-- src/components/scouting/hero-ban-chart.tsx | 2 +- .../scouting/map-performance-chart.tsx | 2 +- .../scouting/match-history-table.tsx | 2 +- .../scouting/scouting-recommendations.tsx | 2 +- .../scouting/team-overview-cards.tsx | 2 +- .../scouting/team-overview-enhanced.tsx | 4 +-- src/components/scouting/team-search.tsx | 2 +- src/components/team/team-settings-form.tsx | 2 +- src/components/user-nav.tsx | 8 +++-- src/data/scrim/index.ts | 30 +++++++++++++++++++ src/lib/permissions.ts | 8 +++-- src/lib/stripe.ts | 8 +++-- 89 files changed, 587 insertions(+), 189 deletions(-) diff --git a/src/app/[team]/compare/page.tsx b/src/app/[team]/compare/page.tsx index 9aa746649..ecc167ba0 100644 --- a/src/app/[team]/compare/page.tsx +++ b/src/app/[team]/compare/page.tsx @@ -1,6 +1,8 @@ import { ComparisonContent } from "@/components/compare/comparison-content"; import { DashboardLayout } from "@/components/dashboard-layout"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; @@ -34,7 +36,9 @@ export default async function ComparePage( notFound(); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { notFound(); } diff --git a/src/app/[team]/map-groups/page.tsx b/src/app/[team]/map-groups/page.tsx index 0f805beb9..07d7480d8 100644 --- a/src/app/[team]/map-groups/page.tsx +++ b/src/app/[team]/map-groups/page.tsx @@ -1,6 +1,8 @@ import { MapGroupManager } from "@/components/compare/map-group-manager"; import { DashboardLayout } from "@/components/dashboard-layout"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; @@ -34,7 +36,9 @@ export default async function MapGroupsPage( notFound(); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { notFound(); } diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 9cfd68502..f5ead8ac0 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -23,7 +23,9 @@ import { Suspense, ViewTransition } from "react"; import { UserNav } from "@/components/user-nav"; import { VodOverview } from "@/components/vods/vod-overview"; import { getMostPlayedHeroes } from "@/data/player-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { aiChat, @@ -95,7 +97,9 @@ export default async function MapDashboardPage( const id = parseInt(params.mapId); const mapDataId = await resolveMapDataId(id); const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const t = await getTranslations("mapPage"); // Tournament context for back navigation diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index 17ef5745e..54621e4a1 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -13,7 +13,9 @@ import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserNav } from "@/components/user-nav"; import { getMostPlayedHeroes } from "@/data/player-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { aiChat, dataLabeling, scoutingTool } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; @@ -78,7 +80,9 @@ export default async function PlayerDashboardPage( }); const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const visibility = (await prisma.scrim.findFirst({ where: { diff --git a/src/app/api/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts index b49bac0af..e8596336a 100644 --- a/src/app/api/(email)/send-team-invite/route.ts +++ b/src/app/api/(email)/send-team-invite/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import TeamInviteUserEmail from "@/components/email/team-invite"; -import { getUser } from "@/data/user-dto"; import { auditLog } from "@/lib/audit-logs"; import { email } from "@/lib/email"; import { createShortLink } from "@/lib/link-service"; @@ -35,7 +37,9 @@ export async function POST(req: NextRequest) { }); if (!teamInviteToken) return new Response("Token not found", { status: 404 }); - const inviter = await getUser(teamInviteToken?.email); + const inviter = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(teamInviteToken?.email))) + ); if (!inviter) return new Response("Inviter not found", { status: 404 }); const team = await prisma.team.findFirst({ @@ -43,7 +47,9 @@ export async function POST(req: NextRequest) { }); if (!team) return new Response("Team not found", { status: 404 }); - const user = await getUser(inviteeEmail); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(inviteeEmail))) + ); if (!user) return new Response("User not found", { status: 404 }); const shortLink = await createShortLink( diff --git a/src/app/api/admin/audit-logs/route.ts b/src/app/api/admin/audit-logs/route.ts index 5b00c0036..f5506e354 100644 --- a/src/app/api/admin/audit-logs/route.ts +++ b/src/app/api/admin/audit-logs/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { $Enums, type AuditLogAction, type Prisma } from "@prisma/client"; @@ -9,7 +11,9 @@ export async function GET(req: NextRequest) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); if (user.role !== $Enums.UserRole.ADMIN) forbidden(); diff --git a/src/app/api/admin/impersonate-user/route.ts b/src/app/api/admin/impersonate-user/route.ts index 6457ca5f2..4e3881ff0 100644 --- a/src/app/api/admin/impersonate-user/route.ts +++ b/src/app/api/admin/impersonate-user/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth, getImpersonateUrl } from "@/lib/auth"; import { $Enums } from "@prisma/client"; @@ -15,7 +17,9 @@ export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); if (user.role !== $Enums.UserRole.ADMIN) unauthorized(); diff --git a/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts b/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts index 6c09f600d..e11d2b938 100644 --- a/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts +++ b/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -35,7 +37,9 @@ export async function PUT(req: Request, props: Params) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); @@ -97,7 +101,9 @@ export async function DELETE(_req: Request, props: Params) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/[id]/anchors/route.ts b/src/app/api/admin/map-calibration/[id]/anchors/route.ts index 1bd9fcaeb..db5f1c4d2 100644 --- a/src/app/api/admin/map-calibration/[id]/anchors/route.ts +++ b/src/app/api/admin/map-calibration/[id]/anchors/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -20,7 +22,9 @@ export async function GET(_req: Request, props: Params) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); @@ -67,7 +71,9 @@ export async function POST(req: Request, props: Params) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/[id]/route.ts b/src/app/api/admin/map-calibration/[id]/route.ts index 51368dc7c..77522ec1e 100644 --- a/src/app/api/admin/map-calibration/[id]/route.ts +++ b/src/app/api/admin/map-calibration/[id]/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -21,7 +23,9 @@ export async function GET(_req: Request, props: Params) { const [session, { id }] = await Promise.all([auth(), props.params]); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); @@ -99,7 +103,9 @@ export async function PUT(req: Request, props: Params) { ]); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); @@ -189,7 +195,9 @@ export async function DELETE(_req: Request, props: Params) { const [session, { id }] = await Promise.all([auth(), props.params]); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/compute-transform/route.ts b/src/app/api/admin/map-calibration/compute-transform/route.ts index bf1c5ee46..1706776d6 100644 --- a/src/app/api/admin/map-calibration/compute-transform/route.ts +++ b/src/app/api/admin/map-calibration/compute-transform/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { computeMapTransform } from "@/lib/map-calibration/compute-transform"; import { Logger } from "@/lib/logger"; @@ -19,7 +21,9 @@ export async function POST(req: Request) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/route.ts b/src/app/api/admin/map-calibration/route.ts index 258200856..066dd277d 100644 --- a/src/app/api/admin/map-calibration/route.ts +++ b/src/app/api/admin/map-calibration/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; @@ -18,7 +20,9 @@ export async function GET() { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); @@ -62,7 +66,9 @@ export async function POST(req: Request) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/upload/presign/route.ts b/src/app/api/admin/map-calibration/upload/presign/route.ts index ca15b6e85..34345c02e 100644 --- a/src/app/api/admin/map-calibration/upload/presign/route.ts +++ b/src/app/api/admin/map-calibration/upload/presign/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; @@ -20,7 +22,9 @@ export async function POST(request: Request): Promise { const session = await auth(); if (!session?.user) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/map-calibration/upload/route.ts b/src/app/api/admin/map-calibration/upload/route.ts index cf290fb82..1a1eb2264 100644 --- a/src/app/api/admin/map-calibration/upload/route.ts +++ b/src/app/api/admin/map-calibration/upload/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; @@ -22,7 +24,9 @@ export async function POST(request: Request): Promise { const session = await auth(); if (!session?.user) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const enabled = await dataLabeling(); diff --git a/src/app/api/admin/user-search/route.ts b/src/app/api/admin/user-search/route.ts index 84b9bc90f..61d968c75 100644 --- a/src/app/api/admin/user-search/route.ts +++ b/src/app/api/admin/user-search/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { Prisma } from "@prisma/client"; @@ -10,7 +12,9 @@ export async function GET(req: NextRequest) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); if (user.role !== $Enums.UserRole.ADMIN) forbidden(); diff --git a/src/app/api/chat/conversations/[id]/route.ts b/src/app/api/chat/conversations/[id]/route.ts index 3b7e1b94e..c59544d17 100644 --- a/src/app/api/chat/conversations/[id]/route.ts +++ b/src/app/api/chat/conversations/[id]/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { Prisma } from "@prisma/client"; @@ -9,7 +11,9 @@ import { NextResponse } from "next/server"; async function getAuthedUser() { const session = await auth(); if (!session?.user?.email) unauthorized(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) unauthorized(); return userData; } diff --git a/src/app/api/chat/conversations/route.ts b/src/app/api/chat/conversations/route.ts index e8b8dabf5..053716d86 100644 --- a/src/app/api/chat/conversations/route.ts +++ b/src/app/api/chat/conversations/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { Prisma } from "@prisma/client"; @@ -9,7 +11,9 @@ export async function GET() { const session = await auth(); if (!session?.user?.email) unauthorized(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) unauthorized(); const conversations = await prisma.chatConversation.findMany({ @@ -25,7 +29,9 @@ export async function POST(req: Request) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) unauthorized(); const body = (await req.json()) as { diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 07dbe9bd2..13f8aa2bb 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { systemPrompt } from "@/lib/ai/system-prompt"; import { chatTelemetry } from "@/lib/ai/telemetry"; import { buildTools } from "@/lib/ai/tools"; @@ -29,7 +31,9 @@ export async function POST(req: Request) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) unauthorized(); const { success } = await ratelimit.limit(userData.id); diff --git a/src/app/api/compare/groups/[id]/route.ts b/src/app/api/compare/groups/[id]/route.ts index 913eef053..4e7aa47fb 100644 --- a/src/app/api/compare/groups/[id]/route.ts +++ b/src/app/api/compare/groups/[id]/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -26,7 +28,9 @@ export async function DELETE( return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/groups/route.ts b/src/app/api/compare/groups/route.ts index 654c310c0..8fbc8ec06 100644 --- a/src/app/api/compare/groups/route.ts +++ b/src/app/api/compare/groups/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -33,7 +35,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -148,7 +152,9 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/map-groups/[id]/route.ts b/src/app/api/compare/map-groups/[id]/route.ts index 0b6bd8879..ccdd2cf68 100644 --- a/src/app/api/compare/map-groups/[id]/route.ts +++ b/src/app/api/compare/map-groups/[id]/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { deleteMapGroup, updateMapGroup } from "@/data/map-group-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -42,7 +44,9 @@ export async function PUT( return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -234,7 +238,9 @@ export async function DELETE( return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/map-groups/route.ts b/src/app/api/compare/map-groups/route.ts index c7527a05e..8b5adc49b 100644 --- a/src/app/api/compare/map-groups/route.ts +++ b/src/app/api/compare/map-groups/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { createMapGroup } from "@/data/map-group-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -32,7 +34,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -144,7 +148,9 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/maps/route.ts b/src/app/api/compare/maps/route.ts index c29e513c6..67b84c57b 100644 --- a/src/app/api/compare/maps/route.ts +++ b/src/app/api/compare/maps/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { getAvailableMapsForComparison } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; @@ -24,7 +26,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/players/route.ts b/src/app/api/compare/players/route.ts index 0d9ea2c3e..dd97efb16 100644 --- a/src/app/api/compare/players/route.ts +++ b/src/app/api/compare/players/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { getTeamPlayers } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { NextRequest } from "next/server"; @@ -22,7 +24,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/compare/stats/route.ts b/src/app/api/compare/stats/route.ts index 24c66b21a..358036c32 100644 --- a/src/app/api/compare/stats/route.ts +++ b/src/app/api/compare/stats/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { getComparisonStats } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; @@ -30,7 +32,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/data-labeling/matches/route.ts b/src/app/api/data-labeling/matches/route.ts index e9df74106..d2a9b2f0e 100644 --- a/src/app/api/data-labeling/matches/route.ts +++ b/src/app/api/data-labeling/matches/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; @@ -30,7 +32,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/data-labeling/save-comp/route.ts b/src/app/api/data-labeling/save-comp/route.ts index dea9b9717..c00d6f321 100644 --- a/src/app/api/data-labeling/save-comp/route.ts +++ b/src/app/api/data-labeling/save-comp/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; @@ -62,7 +64,9 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; diff --git a/src/app/api/notifications/mark-read/route.ts b/src/app/api/notifications/mark-read/route.ts index 9ab405d92..a5e715df4 100644 --- a/src/app/api/notifications/mark-read/route.ts +++ b/src/app/api/notifications/mark-read/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; @@ -15,7 +17,9 @@ export async function POST(request: NextRequest) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const body = bodySchema.safeParse(await request.json()); diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 876bc0b08..c7c258f82 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; @@ -18,7 +20,9 @@ export async function GET(request: NextRequest) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const searchParams = request.nextUrl.searchParams; @@ -73,7 +77,9 @@ export async function POST(request: NextRequest) { if (body.data.userId) { // Explicit user ID provided - verify session user has permission - const sessionUser = await getUser(session.user.email); + const sessionUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!sessionUser) unauthorized(); // For now, allow any authenticated user to create notifications for any user @@ -81,7 +87,9 @@ export async function POST(request: NextRequest) { targetUserId = body.data.userId; } else { // Fallback to session user (backward compatibility) - const sessionUser = await getUser(session.user.email); + const sessionUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!sessionUser) unauthorized(); targetUserId = sessionUser.id; } @@ -129,7 +137,9 @@ export async function DELETE(request: NextRequest) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const searchParams = request.nextUrl.searchParams; diff --git a/src/app/api/reporting/submit-bug-report/route.ts b/src/app/api/reporting/submit-bug-report/route.ts index 62eea3f92..d6c777637 100644 --- a/src/app/api/reporting/submit-bug-report/route.ts +++ b/src/app/api/reporting/submit-bug-report/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { newBugReportWebhookConstructor, @@ -20,7 +22,9 @@ export async function POST(req: NextRequest) { const body = BugReportSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); - const user = await getUser(body.data.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(body.data.email))) + ); const wh = newBugReportWebhookConstructor( body.data.title, diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 3e6cfee6f..85d920342 100644 --- a/src/app/api/scrim/create-scrim/route.ts +++ b/src/app/api/scrim/create-scrim/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { setRequestContext } from "@/lib/axiom/baggage"; @@ -78,7 +80,9 @@ export async function POST(request: NextRequest) { const ua = userAgent(request); event.user_agent = ua.ua; - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); event.user_id = user?.id; event.billing_plan = user?.billingPlan; @@ -124,7 +128,9 @@ export async function POST(request: NextRequest) { return new Response("Invalid map data", { status: 400 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); event.user_id = user?.id; event.billing_plan = user?.billingPlan; diff --git a/src/app/api/scrim/edit-note/route.ts b/src/app/api/scrim/edit-note/route.ts index b8ce9797b..eb4e01f9e 100644 --- a/src/app/api/scrim/edit-note/route.ts +++ b/src/app/api/scrim/edit-note/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { upsertNote } from "@/lib/notes"; @@ -23,7 +25,9 @@ export async function POST(request: NextRequest) { event.user_email = session.user.email; - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { event.outcome = "user_not_found"; event.status_code = 404; diff --git a/src/app/api/scrim/get-scrims/route.ts b/src/app/api/scrim/get-scrims/route.ts index f209eb231..6184b2d7a 100644 --- a/src/app/api/scrim/get-scrims/route.ts +++ b/src/app/api/scrim/get-scrims/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -11,7 +13,9 @@ export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) unauthorized(); const searchParams = req.nextUrl.searchParams; diff --git a/src/app/api/team/avatar-upload/route.ts b/src/app/api/team/avatar-upload/route.ts index 3a376ceb1..e01510784 100644 --- a/src/app/api/team/avatar-upload/route.ts +++ b/src/app/api/team/avatar-upload/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -13,7 +15,9 @@ export async function POST(request: NextRequest): Promise { const session = await auth(); if (!session?.user) unauthorized(); - const userId = await getUser(session.user.email); + const userId = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userId) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } diff --git a/src/app/api/team/create-team/route.ts b/src/app/api/team/create-team/route.ts index af2944ff2..a0c28fa68 100644 --- a/src/app/api/team/create-team/route.ts +++ b/src/app/api/team/create-team/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { @@ -40,7 +42,9 @@ export async function POST(request: NextRequest) { const session = await auth(); - const userId = await getUser(session?.user?.email); + const userId = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!userId) return new Response("Unauthorized", { status: 401 }); const permission = await new Permission("create-team").check(); diff --git a/src/app/api/team/demote-user/route.ts b/src/app/api/team/demote-user/route.ts index f06a0b95d..b4308c8b3 100644 --- a/src/app/api/team/demote-user/route.ts +++ b/src/app/api/team/demote-user/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -33,7 +35,9 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findFirst({ where: { id: userId } }); if (!user) return new Response("User not found", { status: 404 }); - const authedUser = await getUser(session?.user?.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!authedUser && !devTokenAuthed) unauthorized(); diff --git a/src/app/api/team/get-teams-with-perms/route.ts b/src/app/api/team/get-teams-with-perms/route.ts index b004cb40a..b0ec697df 100644 --- a/src/app/api/team/get-teams-with-perms/route.ts +++ b/src/app/api/team/get-teams-with-perms/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -13,7 +15,9 @@ export async function GET() { unauthorized(); } - const userId = await getUser(session?.user?.email); + const userId = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); // find teams where the user is the owner or a manager const teams = await prisma.team.findMany({ diff --git a/src/app/api/team/get-teams/route.ts b/src/app/api/team/get-teams/route.ts index 5a48d3ff2..1081d764e 100644 --- a/src/app/api/team/get-teams/route.ts +++ b/src/app/api/team/get-teams/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -17,7 +19,9 @@ export async function GET() { unauthorized(); } - const userId = await getUser(session?.user?.email); + const userId = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const teams = await prisma.team.findMany({ where: { diff --git a/src/app/api/team/promote-user/route.ts b/src/app/api/team/promote-user/route.ts index 4efd691c7..71236a086 100644 --- a/src/app/api/team/promote-user/route.ts +++ b/src/app/api/team/promote-user/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -38,7 +40,9 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findFirst({ where: { id: userId } }); if (!user) return new Response("User not found", { status: 404 }); - const authedUser = await getUser(session?.user?.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!authedUser && !devTokenAuthed) { return new Response("Unauthorized", { status: 401 }); diff --git a/src/app/api/team/remove-team/route.ts b/src/app/api/team/remove-team/route.ts index 9b9a3678d..ef63ccd1e 100644 --- a/src/app/api/team/remove-team/route.ts +++ b/src/app/api/team/remove-team/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -25,7 +27,9 @@ export async function POST(req: NextRequest) { Logger.log("Authorized removal of team with dev token"); } - const user = await getUser(session?.user?.email ?? "lucas@lux.dev"); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + ); if (!user) return new Response("User not found", { status: 404 }); const team = await prisma.team.findFirst({ where: { id } }); diff --git a/src/app/api/team/remove-user-from-team/route.ts b/src/app/api/team/remove-user-from-team/route.ts index 1cbcc70b6..e58139b48 100644 --- a/src/app/api/team/remove-user-from-team/route.ts +++ b/src/app/api/team/remove-user-from-team/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; @@ -27,7 +29,9 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findFirst({ where: { id: body.data.userId } }); if (!user) return new Response("User not found", { status: 404 }); - const authedUser = await getUser(session.user.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!authedUser) unauthorized(); const managers = await prisma.team.findFirst({ diff --git a/src/app/api/team/targets/[id]/route.ts b/src/app/api/team/targets/[id]/route.ts index c7d78c78d..03d16e902 100644 --- a/src/app/api/team/targets/[id]/route.ts +++ b/src/app/api/team/targets/[id]/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; @@ -30,7 +32,9 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const { id } = await params; @@ -65,7 +69,9 @@ export async function DELETE(req: NextRequest, { params }: RouteParams) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const { id } = await params; diff --git a/src/app/api/team/targets/route.ts b/src/app/api/team/targets/route.ts index b6fd1488e..39e34a86d 100644 --- a/src/app/api/team/targets/route.ts +++ b/src/app/api/team/targets/route.ts @@ -1,5 +1,7 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { getRecentScrimStats } from "@/data/targets-dto"; -import { getUser } from "@/data/user-dto"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; @@ -32,7 +34,9 @@ export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const { searchParams } = new URL(req.url); @@ -60,7 +64,9 @@ export async function POST(req: NextRequest) { const session = await auth(); if (!session?.user?.email) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); // Premium check diff --git a/src/app/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts index 9650f7e71..79cb7690a 100644 --- a/src/app/api/team/transfer-ownership/route.ts +++ b/src/app/api/team/transfer-ownership/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -27,7 +29,9 @@ export async function POST(req: NextRequest) { Logger.log("Authorized removal of team with dev token"); } - const user = await getUser(session?.user?.email ?? "lucas@lux.dev"); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + ); if (!user) return new Response("User not found", { status: 404 }); const team = await prisma.team.findFirst({ where: { id } }); @@ -40,7 +44,9 @@ export async function POST(req: NextRequest) { if (!hasPerms) forbidden(); - const newOwner = await getUser(owner); + const newOwner = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(owner))) + ); if (!newOwner) return new Response("New owner not found", { status: 404 }); await prisma.team.update({ diff --git a/src/app/api/team/update-avatar/route.ts b/src/app/api/team/update-avatar/route.ts index e63da192f..fef93b066 100644 --- a/src/app/api/team/update-avatar/route.ts +++ b/src/app/api/team/update-avatar/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -34,7 +36,9 @@ export async function POST(req: NextRequest) { where: { teamId: body.data.teamId }, }); - const authedUser = await getUser(session.user.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!authedUser) { Logger.error(`User not found: ${session.user.email}`); unauthorized(); diff --git a/src/app/api/tournament/create/route.ts b/src/app/api/tournament/create/route.ts index 4dbf1ad10..37bfc4284 100644 --- a/src/app/api/tournament/create/route.ts +++ b/src/app/api/tournament/create/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { @@ -109,7 +111,9 @@ export async function POST(request: NextRequest) { ); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { event.outcome = "user_not_found"; event.statusCode = 404; diff --git a/src/app/api/user/app-settings/route.ts b/src/app/api/user/app-settings/route.ts index 6e7eed4e8..cf8ae2a4f 100644 --- a/src/app/api/user/app-settings/route.ts +++ b/src/app/api/user/app-settings/route.ts @@ -1,4 +1,6 @@ -import { getAppSettings, getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -25,11 +27,15 @@ export async function GET() { } try { - let appSettings = await getAppSettings(session.user.email); + let appSettings = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getAppSettings(session.user.email))) + ); // If no app settings exist, create default ones if (!appSettings) { - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { Logger.error("User not found when creating app settings"); return new Response("User not found", { status: 404 }); @@ -82,7 +88,9 @@ export async function PUT(request: NextRequest) { const body = await request.json(); const validatedData = updateAppSettingsSchema.parse(body); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { Logger.error("User not found when updating app settings"); return new Response("User not found", { status: 404 }); diff --git a/src/app/api/user/avatar-upload/route.ts b/src/app/api/user/avatar-upload/route.ts index 6f78a5f39..d1144aa12 100644 --- a/src/app/api/user/avatar-upload/route.ts +++ b/src/app/api/user/avatar-upload/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -16,7 +18,9 @@ export async function POST(request: NextRequest): Promise { unauthorized(); } - const authedUser = await getUser(session.user.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!authedUser) { return NextResponse.json({ error: "User not found" }, { status: 404 }); diff --git a/src/app/api/user/banner-upload/route.ts b/src/app/api/user/banner-upload/route.ts index f34d908f5..2d0837ecd 100644 --- a/src/app/api/user/banner-upload/route.ts +++ b/src/app/api/user/banner-upload/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -16,7 +18,9 @@ export async function POST(request: NextRequest): Promise { unauthorized(); } - const authedUser = await getUser(session.user.email); + const authedUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!authedUser) { return NextResponse.json({ error: "User not found" }, { status: 404 }); diff --git a/src/app/api/user/delete-account/route.ts b/src/app/api/user/delete-account/route.ts index e2fb344a5..a3655e2a3 100644 --- a/src/app/api/user/delete-account/route.ts +++ b/src/app/api/user/delete-account/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -15,7 +17,9 @@ export async function DELETE() { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); await track("User Deleted Account", { email: user.email }); diff --git a/src/app/api/vod/route.ts b/src/app/api/vod/route.ts index b08f6fd81..877882c1b 100644 --- a/src/app/api/vod/route.ts +++ b/src/app/api/vod/route.ts @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { NextResponse } from "next/server"; @@ -28,7 +30,9 @@ export async function POST(req: Request) { if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { Logger.error("User not found for session: ", session.user); diff --git a/src/app/chat/[conversationId]/page.tsx b/src/app/chat/[conversationId]/page.tsx index 4b9ffd0bc..bc67a1b4c 100644 --- a/src/app/chat/[conversationId]/page.tsx +++ b/src/app/chat/[conversationId]/page.tsx @@ -1,5 +1,7 @@ import { ChatInterface } from "@/components/chat/chat-interface"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { UIMessage } from "ai"; @@ -14,7 +16,9 @@ export default async function ConversationPage({ const session = await auth(); if (!session?.user?.email) notFound(); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) notFound(); const conversation = await prisma.chatConversation.findFirst({ diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b8eaa3c74..a9d3a03e6 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -2,7 +2,9 @@ import { DirectionalTransition } from "@/components/directional-transition"; import { ScrimPagination } from "@/components/dashboard/scrim-pagination"; import { UpdateModalWrapper } from "@/components/dashboard/update-modal-wrapper"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import type { PagePropsWithLocale } from "@/types/next"; import { $Enums } from "@prisma/client"; @@ -48,7 +50,11 @@ export default async function DashboardPage() { getTranslations("dashboard"), ]); - const userData = await getUser(session?.user?.email); + const userData = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session?.user?.email)) + ) + ); const isAdmin = userData?.role === $Enums.UserRole.ADMIN; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c51d53c84..1c86cb571 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,9 @@ import { AppSettingsProvider } from "@/components/settings/app-settings-provider import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { register } from "@/instrumentation"; import { auth } from "@/lib/auth"; import { WebVitals } from "@/lib/axiom/client"; @@ -69,7 +71,11 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { let user = null; if (session) { - user = await getUser(session.user.email); + user = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session.user.email)) + ) + ); } const flags = await resolveAllFlags(); diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 07e43edf8..774d03a39 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -1,5 +1,7 @@ import { NotificationsPage } from "@/components/notifications/notifications-page"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { unauthorized } from "next/navigation"; @@ -7,7 +9,9 @@ export default async function Notifications() { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); return ; diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 7c00777c7..7f64af7c8 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -11,7 +11,9 @@ import { PricingFaq } from "@/components/pricing/pricing-faq"; import { PricingHero } from "@/components/pricing/pricing-hero"; import { PricingStructuredData } from "@/components/pricing/pricing-structured-data"; import { Link } from "@/components/ui/link"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { createCheckout, getCustomerPortalUrl } from "@/lib/stripe"; import { toTitleCase } from "@/lib/utils"; @@ -97,7 +99,9 @@ export default async function PricingPage() { const t = await getTranslations("pricingPage"); const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const plan = toTitleCase(user?.billingPlan ?? ""); const isLoggedIn = !!session?.user; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index da6c0a1a3..b1ee69d68 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,10 +1,14 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { notFound, redirect } from "next/navigation"; export default async function BaseProfilePage() { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!user) redirect("/sign-in"); if (!user.battletag) notFound(); diff --git a/src/app/reports/[id]/page.tsx b/src/app/reports/[id]/page.tsx index 59832ea76..b61569139 100644 --- a/src/app/reports/[id]/page.tsx +++ b/src/app/reports/[id]/page.tsx @@ -1,5 +1,7 @@ import { MessageResponse } from "@/components/ai-elements/message"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { Metadata } from "next"; @@ -33,7 +35,9 @@ export default async function ReportPage({ redirect("/sign-in"); } - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) redirect("/sign-in"); const report = await prisma.chatReport.findUnique({ diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 1e3bf19f1..1e15c49d8 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -1,4 +1,6 @@ -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { redirect } from "next/navigation"; @@ -8,7 +10,9 @@ export default async function ReportsPage() { const session = await auth(); if (!session?.user?.email) redirect("/sign-in"); - const userData = await getUser(session.user.email); + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!userData) redirect("/sign-in"); const reports = await prisma.chatReport.findMany({ diff --git a/src/app/settings/accounts/page.tsx b/src/app/settings/accounts/page.tsx index 5f94959c1..de6c656b8 100644 --- a/src/app/settings/accounts/page.tsx +++ b/src/app/settings/accounts/page.tsx @@ -9,7 +9,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { getTranslations } from "next-intl/server"; @@ -23,7 +25,9 @@ export default async function LinkedAccountSettingsPage() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); diff --git a/src/app/settings/admin/analytics/layout.tsx b/src/app/settings/admin/analytics/layout.tsx index 07e79f38e..742ed4dfc 100644 --- a/src/app/settings/admin/analytics/layout.tsx +++ b/src/app/settings/admin/analytics/layout.tsx @@ -1,5 +1,7 @@ import { NoAuthCard } from "@/components/auth/no-auth"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; @@ -8,7 +10,9 @@ export default async function AdminAnalyticsLayout({ }: LayoutProps<"/settings/admin/analytics">) { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (user?.role !== $Enums.UserRole.ADMIN) { return NoAuthCard(); diff --git a/src/app/settings/admin/analytics/page.tsx b/src/app/settings/admin/analytics/page.tsx index 44f13747d..07e1ca681 100644 --- a/src/app/settings/admin/analytics/page.tsx +++ b/src/app/settings/admin/analytics/page.tsx @@ -13,7 +13,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { $Enums } from "@prisma/client"; @@ -244,7 +246,9 @@ export default async function AdminAnalyticsPage() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); } diff --git a/src/app/settings/admin/audit-logs/layout.tsx b/src/app/settings/admin/audit-logs/layout.tsx index c5c258c05..1bbc1fc3c 100644 --- a/src/app/settings/admin/audit-logs/layout.tsx +++ b/src/app/settings/admin/audit-logs/layout.tsx @@ -1,5 +1,7 @@ import { NoAuthCard } from "@/components/auth/no-auth"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; @@ -8,7 +10,9 @@ export default async function AdminLayout({ }: LayoutProps<"/settings/admin/audit-logs">) { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (user?.role !== $Enums.UserRole.ADMIN) { return NoAuthCard(); diff --git a/src/app/settings/admin/audit-logs/page.tsx b/src/app/settings/admin/audit-logs/page.tsx index 1cbc6ce1a..c401a364a 100644 --- a/src/app/settings/admin/audit-logs/page.tsx +++ b/src/app/settings/admin/audit-logs/page.tsx @@ -1,6 +1,8 @@ import { AuditLog } from "@/components/admin/audit-log"; import { NoAuthCard } from "@/components/auth/no-auth"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; import { redirect } from "next/navigation"; @@ -11,7 +13,9 @@ export default async function AuditLogsPage() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); diff --git a/src/app/settings/admin/impersonate-user/layout.tsx b/src/app/settings/admin/impersonate-user/layout.tsx index 86fdebb99..f2ef7ec62 100644 --- a/src/app/settings/admin/impersonate-user/layout.tsx +++ b/src/app/settings/admin/impersonate-user/layout.tsx @@ -1,5 +1,7 @@ import { NoAuthCard } from "@/components/auth/no-auth"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; @@ -8,7 +10,9 @@ export default async function AdminLayout({ }: LayoutProps<"/settings/admin/impersonate-user">) { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (user?.role !== $Enums.UserRole.ADMIN) { return NoAuthCard(); diff --git a/src/app/settings/admin/impersonate-user/page.tsx b/src/app/settings/admin/impersonate-user/page.tsx index f3b701116..3526bd6f4 100644 --- a/src/app/settings/admin/impersonate-user/page.tsx +++ b/src/app/settings/admin/impersonate-user/page.tsx @@ -1,7 +1,9 @@ import { ImpersonateUserForm } from "@/components/admin/impersonate-user"; import { NoAuthCard } from "@/components/auth/no-auth"; import { Separator } from "@/components/ui/separator"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; import { getTranslations } from "next-intl/server"; @@ -15,7 +17,9 @@ export default async function AdminSettingsPage() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); diff --git a/src/app/settings/admin/page.tsx b/src/app/settings/admin/page.tsx index cf2df550c..ad5988a60 100644 --- a/src/app/settings/admin/page.tsx +++ b/src/app/settings/admin/page.tsx @@ -10,7 +10,9 @@ import { CardTitle, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; import { getTranslations } from "next-intl/server"; @@ -22,7 +24,9 @@ export default async function AdminDashboard() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); } diff --git a/src/app/settings/billing/page.tsx b/src/app/settings/billing/page.tsx index 473bda7c0..4dfdbf232 100644 --- a/src/app/settings/billing/page.tsx +++ b/src/app/settings/billing/page.tsx @@ -1,6 +1,8 @@ import { UsageCard } from "@/components/settings/usage-card"; import { Separator } from "@/components/ui/separator"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { getCustomerPortalUrl } from "@/lib/stripe"; @@ -14,7 +16,9 @@ export default async function SettingsBillingPage() { const session = await auth(); if (!session?.user) redirect("/sign-in"); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!user) redirect("/sign-up"); const billingPortalUrl = (await getCustomerPortalUrl(user)) as Route; diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx index 6854c2022..d412cf646 100644 --- a/src/app/settings/layout.tsx +++ b/src/app/settings/layout.tsx @@ -1,7 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { SidebarNav } from "@/components/settings/sidebar-nav"; import { Separator } from "@/components/ui/separator"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; import type { Metadata, Route } from "next"; @@ -75,7 +77,9 @@ export default async function SettingsLayout({ const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const isAdmin = user?.role === $Enums.UserRole.ADMIN; diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 056d65c02..7be9f9c34 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,7 +1,9 @@ import { DangerZone } from "@/components/settings/danger-zone"; import { ProfileForm } from "@/components/settings/profile-form"; import { Separator } from "@/components/ui/separator"; -import { getAppSettings, getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { getCustomerPortalUrl } from "@/lib/stripe"; @@ -17,13 +19,19 @@ export default async function SettingsProfilePage() { redirect("/sign-in"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { redirect("/sign-up"); } - const appSettings = await getAppSettings(session.user.email); + const appSettings = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getAppSettings(session.user.email)) + ) + ); const billingPortalUrl = (await getCustomerPortalUrl(user)) as Route; const appliedTitle = await prisma.appliedTitle.findFirst({ diff --git a/src/app/stats/hero/[heroName]/page.tsx b/src/app/stats/hero/[heroName]/page.tsx index 139efc35a..931403751 100644 --- a/src/app/stats/hero/[heroName]/page.tsx +++ b/src/app/stats/hero/[heroName]/page.tsx @@ -9,7 +9,9 @@ import { getAllKillsForHero, getAllStatsForHero, } from "@/data/hero-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; @@ -63,7 +65,9 @@ export default async function HeroStats( if (heroRoleMapping[hero as HeroName] === undefined) notFound(); const session = await auth(); - const user = await getUser(session?.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); const [timeframe1, timeframe2, timeframe3] = await Promise.all([ new Permission("stats-timeframe-1").check(), diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx index cfb8301cd..3b9356155 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -66,7 +66,9 @@ import { import { getTeamAbilityImpact } from "@/data/team-ability-impact-dto"; import { getTeamUltImpact } from "@/data/team-ult-impact-dto"; import { getTeamUltStats } from "@/data/team-ult-stats-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { simulationTool, ultimateImpactTool } from "@/lib/flags"; import { calculateHeroPickrateMatrix } from "@/lib/hero-pickrate-utils"; @@ -137,7 +139,9 @@ export default async function TeamStatsPage( } ) { const session = await auth(); - const user = await getUser(session?.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); if (!user) notFound(); const params = await props.params; diff --git a/src/app/stats/team/page.tsx b/src/app/stats/team/page.tsx index 5ec9cc9de..231911e42 100644 --- a/src/app/stats/team/page.tsx +++ b/src/app/stats/team/page.tsx @@ -5,14 +5,18 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { redirect } from "next/navigation"; export default async function TeamStatsPage() { const session = await auth(); - const user = await getUser(session?.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); if (!user) redirect("/sign-in"); const teams = await prisma.team.findMany({ diff --git a/src/app/team/[teamId]/page.tsx b/src/app/team/[teamId]/page.tsx index 6442eaa8e..e02cee477 100644 --- a/src/app/team/[teamId]/page.tsx +++ b/src/app/team/[teamId]/page.tsx @@ -11,7 +11,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { getScoutingTeams } from "@/data/scouting-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; @@ -101,7 +103,9 @@ export default async function Team( const teamMembers = teamMembersData ?? { users: [] }; - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); function userIsManager(user: { id: string }) { return teamManagers.some((manager) => manager.userId === user.id); diff --git a/src/app/team/[teamId]/targets/page.tsx b/src/app/team/[teamId]/targets/page.tsx index 298d73ef2..09d2cb2d4 100644 --- a/src/app/team/[teamId]/targets/page.tsx +++ b/src/app/team/[teamId]/targets/page.tsx @@ -7,7 +7,9 @@ import { type TargetProgress, } from "@/data/targets-dto"; import { getTeamRoster } from "@/data/team-shared-data"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { RoleName } from "@/lib/target-stats"; @@ -23,7 +25,9 @@ export default async function TeamTargetsPage(props: Props) { const teamId = parseInt(params.teamId); const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!user) { return ( diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx index e1531df70..c1cca3338 100644 --- a/src/app/team/page.tsx +++ b/src/app/team/page.tsx @@ -2,7 +2,9 @@ import { EmptyTeamView } from "@/components/team/empty-team-view"; import { Card, CardFooter, CardHeader } from "@/components/ui/card"; import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; @@ -43,7 +45,11 @@ export default async function TeamPage() { const session = await auth(); - const userData = await getUser(session?.user?.email); + const userData = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session?.user?.email)) + ) + ); const userTeams = await prisma.team.findMany({ where: { users: { some: { id: userData?.id } } }, diff --git a/src/components/dashboard-layout.tsx b/src/components/dashboard-layout.tsx index d2f241ed6..2bc109d25 100644 --- a/src/components/dashboard-layout.tsx +++ b/src/components/dashboard-layout.tsx @@ -9,7 +9,8 @@ import { Notifications } from "@/components/notifications"; import { TeamSwitcherProvider } from "@/components/team-switcher-provider"; import { ModeToggle } from "@/components/theme-switcher"; import { UserNav } from "@/components/user-nav"; -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { aiChat, @@ -18,6 +19,7 @@ import { scoutingTool, tournament, } from "@/lib/flags"; +import { Effect } from "effect"; export async function DashboardLayout({ children, @@ -41,7 +43,9 @@ export async function DashboardLayout({ tournament(), coachingCanvas(), ]); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); return ( diff --git a/src/components/scouting/hero-ban-chart.tsx b/src/components/scouting/hero-ban-chart.tsx index f33b5485e..5cb81cd5b 100644 --- a/src/components/scouting/hero-ban-chart.tsx +++ b/src/components/scouting/hero-ban-chart.tsx @@ -13,7 +13,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { ScoutingHeroBans } from "@/data/scouting-dto"; +import type { ScoutingHeroBans } from "@/data/scouting"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/scouting/map-performance-chart.tsx b/src/components/scouting/map-performance-chart.tsx index 48d0ad259..02925ded1 100644 --- a/src/components/scouting/map-performance-chart.tsx +++ b/src/components/scouting/map-performance-chart.tsx @@ -14,7 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { ScoutingMapAnalysis } from "@/data/scouting-dto"; +import type { ScoutingMapAnalysis } from "@/data/scouting"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/scouting/match-history-table.tsx b/src/components/scouting/match-history-table.tsx index 48fb69c98..284799a96 100644 --- a/src/components/scouting/match-history-table.tsx +++ b/src/components/scouting/match-history-table.tsx @@ -18,7 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { ScoutingMatchHistoryEntry } from "@/data/scouting-dto"; +import type { ScoutingMatchHistoryEntry } from "@/data/scouting"; import { useTranslations } from "next-intl"; import { useState } from "react"; diff --git a/src/components/scouting/scouting-recommendations.tsx b/src/components/scouting/scouting-recommendations.tsx index 4d99274d3..59e135bc3 100644 --- a/src/components/scouting/scouting-recommendations.tsx +++ b/src/components/scouting/scouting-recommendations.tsx @@ -11,7 +11,7 @@ import { import type { ScoutingRecommendation, ScoutingRecommendations as ScoutingRecommendationsType, -} from "@/data/scouting-dto"; +} from "@/data/scouting"; import { assessConfidence } from "@/lib/confidence"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-overview-cards.tsx b/src/components/scouting/team-overview-cards.tsx index b55636b73..eb9308f1f 100644 --- a/src/components/scouting/team-overview-cards.tsx +++ b/src/components/scouting/team-overview-cards.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { MatchResult, ScoutingTeamOverview } from "@/data/scouting-dto"; +import type { MatchResult, ScoutingTeamOverview } from "@/data/scouting"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-overview-enhanced.tsx b/src/components/scouting/team-overview-enhanced.tsx index a6f8ff721..311a5cbd1 100644 --- a/src/components/scouting/team-overview-enhanced.tsx +++ b/src/components/scouting/team-overview-enhanced.tsx @@ -9,12 +9,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { TeamStrengthRating } from "@/data/opponent-strength-dto"; import type { MatchResult, ScoutingMatchHistoryEntry, ScoutingTeamOverview, -} from "@/data/scouting-dto"; + TeamStrengthRating, +} from "@/data/scouting"; import { assessConfidence } from "@/lib/confidence"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-search.tsx b/src/components/scouting/team-search.tsx index b91d173f7..5ac4f1520 100644 --- a/src/components/scouting/team-search.tsx +++ b/src/components/scouting/team-search.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ScoutingTeam } from "@/data/scouting-dto"; +import type { ScoutingTeam } from "@/data/scouting"; import { cn } from "@/lib/utils"; import Fuse from "fuse.js"; import { Search } from "lucide-react"; diff --git a/src/components/team/team-settings-form.tsx b/src/components/team/team-settings-form.tsx index b01bc0ebb..0c0655ee4 100644 --- a/src/components/team/team-settings-form.tsx +++ b/src/components/team/team-settings-form.tsx @@ -31,7 +31,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { ScoutingTeam } from "@/data/scouting-dto"; +import type { ScoutingTeam } from "@/data/scouting"; import { ClientOnly } from "@/lib/client-only"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; diff --git a/src/components/user-nav.tsx b/src/components/user-nav.tsx index 4b10568d8..38c2c1c21 100644 --- a/src/components/user-nav.tsx +++ b/src/components/user-nav.tsx @@ -13,10 +13,12 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Link } from "@/components/ui/link"; -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums } from "@prisma/client"; import { ExternalLinkIcon } from "@radix-ui/react-icons"; +import { Effect } from "effect"; import type { Route } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; @@ -27,7 +29,9 @@ export async function UserNav() { redirect("/sign-in"); } - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const isAdmin = user?.role === $Enums.UserRole.ADMIN; diff --git a/src/data/scrim/index.ts b/src/data/scrim/index.ts index 27f12d08b..121ff6a83 100644 --- a/src/data/scrim/index.ts +++ b/src/data/scrim/index.ts @@ -29,3 +29,33 @@ export { PlayerNameSchema, OpponentAbbrSchema, } from "./types"; + +export type { + AbilityTimingAnalysis, + AbilityTimingOutlier, + AbilityTimingRow, + FightPhase, + FightTimeline, + MapAbilityTimingAnalysis, + MapResult, + PlayerMapPerformance, + PlayerScrimPerformance, + ScrimFightAnalysis, + ScrimInsight, + ScrimOutlier, + ScrimOverviewData, + ScrimSwapAnalysis, + ScrimTeamTotals, + ScrimUltAnalysis, + UltEfficiency, +} from "./types"; + +export { + assignPlayersToSubroles, + buildPlayerUltComparisons, +} from "./ult-helpers"; +export type { + PlayerUltComparison, + PlayerUltSummary, + SubroleUltTiming, +} from "./ult-helpers"; diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index e55a1f2e4..fcd0d11dd 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -1,7 +1,9 @@ -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { $Enums, type User } from "@prisma/client"; import { get } from "@vercel/edge-config"; +import { Effect } from "effect"; const FEATURES = [ "create-team", @@ -69,7 +71,9 @@ export class Permission { return { user: null, isAuthed: false }; } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { return { user: null, isAuthed: false }; } diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 2c3da3de2..c2bc56f49 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -1,7 +1,9 @@ -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import type { BillingPlans } from "@/types/billing-plans"; import type { User } from "@prisma/client"; import { get } from "@vercel/edge-config"; +import { Effect } from "effect"; import type { Session } from "next-auth"; import Stripe from "stripe"; @@ -25,7 +27,9 @@ export async function createCheckout( throw new Error("Unauthorized"); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { throw new Error("Unauthorized"); From eb58ec5102160f275254c63b855aeabfb63929c3 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:31:22 -0400 Subject: [PATCH 17/42] Migrate scrim-dto consumers to native Effect Switch 39 files from scrim-dto, scrim-overview-dto, and scrim-ability-timing-dto to ScrimService, ScrimOverviewService, and ScrimAbilityTimingService via AppRuntime. Update scrim barrel exports to re-export all consumer-facing types and utilities. --- src/app/[team]/scrim/[scrimId]/edit/page.tsx | 18 +++++++--- src/app/[team]/scrim/[scrimId]/page.tsx | 14 +++++--- src/app/api/player/stats/route.ts | 31 +++++++++-------- src/app/api/scrim/remove-map/route.ts | 14 +++++--- src/app/api/scrim/remove-scrim/route.ts | 14 +++++--- .../api/scrim/update-scrim-options/route.ts | 14 +++++--- src/app/profile/[playerName]/page.tsx | 31 +++++++++-------- src/app/stats/[playerName]/page.tsx | 34 +++++++++++-------- src/app/stats/compare/page.tsx | 2 +- .../charts/player/player-charts.tsx | 10 ++++-- src/components/charts/ult-timing-chart.tsx | 2 +- src/components/map/analysis/analysis-card.tsx | 2 +- .../map/analysis/analysis-sections.tsx | 2 +- .../map/analysis/efficiency-scorecard.tsx | 2 +- .../analysis/map-ability-timing-section.tsx | 2 +- src/components/map/default-overview.tsx | 31 +++++++++++------ src/components/map/player-card.tsx | 10 ++++-- src/components/player/default-overview.tsx | 12 +++++-- src/components/scrim/fight-win-rate-chart.tsx | 2 +- .../scrim/player-performance-hover-chart.tsx | 2 +- .../scrim/player-performance-table.tsx | 2 +- .../scrim/scrim-ability-timing-section.tsx | 2 +- src/components/scrim/scrim-overview-card.tsx | 12 +++++-- .../scrim/scrim-overview-sections.tsx | 2 +- src/components/scrim/scrim-overview-tabs.tsx | 2 +- .../scrim/scrim-raw-stats-sections.tsx | 2 +- src/components/scrim/ult-comparison-chart.tsx | 2 +- .../stats/compare/comparison-view.tsx | 2 +- .../stats/player/charts/map-wins-chart.tsx | 2 +- .../stats/player/charts/wins-per-map-type.tsx | 2 +- src/components/stats/player/range-picker.tsx | 2 +- src/components/stats/player/statistics.tsx | 2 +- src/lib/ai/formatters.ts | 2 +- src/lib/ai/tools.ts | 25 +++++++++----- src/lib/analytics.ts | 12 +++++-- src/lib/auth.ts | 30 +++++++++++----- src/lib/calculate-stats.ts | 12 +++++-- src/lib/mvp-score.ts | 12 +++++-- src/lib/notes.ts | 18 +++++++--- 39 files changed, 261 insertions(+), 133 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 562d0b92c..7dcb46d49 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -3,8 +3,10 @@ import { DangerZone } from "@/components/scrim/danger-zone"; import { EditScrimForm } from "@/components/scrim/edit-scrim-form"; import { Link } from "@/components/ui/link"; import { getScoutingTeams } from "@/data/scouting-dto"; -import { getScrim } from "@/data/scrim-dto"; -import { getTeamsWithPerms } from "@/data/user-dto"; +import { ScrimService } from "@/data/scrim"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; @@ -16,7 +18,9 @@ export default async function EditScrimPage( props: PageProps<"/[team]/scrim/[scrimId]/edit"> ) { const params = await props.params; - const scrim = await getScrim(parseInt(params.scrimId)); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(params.scrimId)))) + ); const session = await auth(); const t = await getTranslations("scrimPage.editScrim"); @@ -26,7 +30,13 @@ export default async function EditScrimPage( const [teamsWithPerms, scoutingTeamList, scoutingEnabled] = await Promise.all( [ - getTeamsWithPerms(session?.user?.email), + AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => + svc.getTeamsWithPerms(session?.user?.email) + ) + ) + ), getScoutingTeams(), scoutingTool(), ] diff --git a/src/app/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx index 4c4eeeb82..7e052b2a1 100644 --- a/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/page.tsx @@ -13,8 +13,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { getScrim } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { ScrimService } from "@/data/scrim"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { mapComparison, overviewCard } from "@/lib/flags"; import prisma from "@/lib/prisma"; @@ -76,7 +78,9 @@ export default async function ScrimDashboardPage( const session = await auth(); const t = await getTranslations("scrimPage"); - const scrim = await getScrim(id); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(id))) + ); if (!scrim) notFound(); const teamId = scrim.teamId; @@ -89,7 +93,9 @@ export default async function ScrimDashboardPage( }) ).sort((a, b) => a.id - b.id); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); const isManager = teamId ? (await prisma.teamManager.findFirst({ diff --git a/src/app/api/player/stats/route.ts b/src/app/api/player/stats/route.ts index 5a053db5f..8c933a7f9 100644 --- a/src/app/api/player/stats/route.ts +++ b/src/app/api/player/stats/route.ts @@ -1,10 +1,7 @@ -import { - getAllDeathsForPlayer, - getAllKillsForPlayer, - getAllMapWinratesForPlayer, - getAllStatsForPlayer, -} from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ScrimService } from "@/data/scrim"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { Permission } from "@/lib/permissions"; @@ -20,7 +17,9 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) { return new Response("User not found", { status: 404 }); } @@ -101,13 +100,15 @@ export async function GET(request: NextRequest) { const permittedScrimIds = data[permitted].map((scrim) => scrim.id); try { - const [allPlayerStats, allPlayerKills, mapWinrates, allPlayerDeaths] = - await Promise.all([ - getAllStatsForPlayer(permittedScrimIds, name), - getAllKillsForPlayer(permittedScrimIds, name), - getAllMapWinratesForPlayer(permittedScrimIds, name), - getAllDeathsForPlayer(permittedScrimIds, name), - ]); + const { allPlayerStats, allPlayerKills, mapWinrates, allPlayerDeaths } = + await AppRuntime.runPromise( + Effect.all({ + allPlayerStats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), + allPlayerKills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), + mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), + allPlayerDeaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), + }, { concurrency: "unbounded" }) + ); return NextResponse.json({ success: true, diff --git a/src/app/api/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts index d6934ea84..73473484a 100644 --- a/src/app/api/scrim/remove-map/route.ts +++ b/src/app/api/scrim/remove-map/route.ts @@ -1,5 +1,7 @@ -import { getScrim } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { mapDeletionDuration, mapRemovedCounter } from "@/lib/axiom/metrics"; @@ -24,7 +26,9 @@ export async function POST(req: NextRequest) { Logger.log("Authorized removal of map with dev token"); } - const user = await getUser(session?.user?.email ?? "lucas@lux.dev"); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + ); if (!user) return new Response("User not found", { status: 404 }); if (!id) return new Response("Missing ID", { status: 400 }); @@ -34,7 +38,9 @@ export async function POST(req: NextRequest) { }); if (!map) return new Response("Map not found", { status: 404 }); - const scrim = await getScrim(map.scrimId!); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(map.scrimId!))) + ); if (!scrim) return new Response("Scrim not found", { status: 404 }); let isManager = false; diff --git a/src/app/api/scrim/remove-scrim/route.ts b/src/app/api/scrim/remove-scrim/route.ts index c38d0c62f..e07a53deb 100644 --- a/src/app/api/scrim/remove-scrim/route.ts +++ b/src/app/api/scrim/remove-scrim/route.ts @@ -1,5 +1,7 @@ -import { getScrim } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; @@ -24,12 +26,16 @@ export async function POST(req: NextRequest) { Logger.log("Authorized removal of scrim with dev token"); } - const user = await getUser(session?.user?.email ?? "lucas@lux.dev"); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + ); if (!user) return new Response("User not found", { status: 404 }); if (!id) return new Response("Missing ID", { status: 400 }); - const scrim = await getScrim(parseInt(id)); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(id)))) + ); if (!scrim) return new Response("Scrim not found", { status: 404 }); diff --git a/src/app/api/scrim/update-scrim-options/route.ts b/src/app/api/scrim/update-scrim-options/route.ts index 02a2705f8..fda8bb901 100644 --- a/src/app/api/scrim/update-scrim-options/route.ts +++ b/src/app/api/scrim/update-scrim-options/route.ts @@ -1,5 +1,7 @@ -import { getScrim } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; @@ -42,14 +44,18 @@ export async function POST(req: NextRequest) { const body = UpdateScrimSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); - const user = await getUser(session.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); if (!user) unauthorized(); const userIsManager = await prisma.teamManager.findFirst({ where: { userId: user.id }, }); - const scrim = await getScrim(body.data.scrimId); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(body.data.scrimId))) + ); if (!scrim) return new Response("Scrim not found", { status: 404 }); const hasPerms = diff --git a/src/app/profile/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx index 99401428e..517f675a8 100644 --- a/src/app/profile/[playerName]/page.tsx +++ b/src/app/profile/[playerName]/page.tsx @@ -13,19 +13,16 @@ import { import { PlayerTargetsTab } from "@/components/targets/player-targets-tab"; import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - getAllDeathsForPlayer, - getAllKillsForPlayer, - getAllMapWinratesForPlayer, - getAllStatsForPlayer, -} from "@/data/scrim-dto"; +import { ScrimService } from "@/data/scrim"; import { calculateTargetProgress, getPlayerTargets, getRecentScrimStats, type TargetProgress, } from "@/data/targets-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; import { Permission } from "@/lib/permissions"; @@ -273,12 +270,14 @@ export default async function ProfilePage( const permittedScrimIds = data[permitted].map((scrim) => scrim.id); - const [stats, kills, deaths, mapWinrates] = await Promise.all([ - getAllStatsForPlayer(permittedScrimIds, name), - getAllKillsForPlayer(permittedScrimIds, name), - getAllDeathsForPlayer(permittedScrimIds, name), - getAllMapWinratesForPlayer(permittedScrimIds, name), - ]); + const { stats, kills, deaths, mapWinrates } = await AppRuntime.runPromise( + Effect.all({ + stats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), + kills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), + deaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), + mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), + }, { concurrency: "unbounded" }) + ); // Calculate max time for bar chart scaling const maxTimePlayed = Math.max( @@ -287,7 +286,11 @@ export default async function ProfilePage( // Targets tab: visible on own profile or for site admins const session = await auth(); - const sessionUser = await getUser(session?.user?.email); + const sessionUser = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session?.user?.email)) + ) + ); const isOwnProfile = user && session?.user?.email && session.user.email === user.email; const isAdmin = sessionUser?.role === $Enums.UserRole.ADMIN; diff --git a/src/app/stats/[playerName]/page.tsx b/src/app/stats/[playerName]/page.tsx index 9e99b2679..18a525c6f 100644 --- a/src/app/stats/[playerName]/page.tsx +++ b/src/app/stats/[playerName]/page.tsx @@ -4,13 +4,10 @@ import { } from "@/components/stats/player/range-picker"; import { Card } from "@/components/ui/card"; import { Link } from "@/components/ui/link"; -import { - getAllDeathsForPlayer, - getAllKillsForPlayer, - getAllMapWinratesForPlayer, - getAllStatsForPlayer, -} from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { ScrimService } from "@/data/scrim"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; @@ -60,7 +57,9 @@ export default async function PlayerStats( const name = decodeURIComponent(params.playerName); const session = await auth(); - const user = await getUser(session?.user.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); const [timeframe1, timeframe2, timeframe3] = await Promise.all([ new Permission("stats-timeframe-1").check(), @@ -146,13 +145,18 @@ export default async function PlayerStats( let allPlayerDeaths: Kill[]; try { - [allPlayerStats, allPlayerKills, mapWinrates, allPlayerDeaths] = - await Promise.all([ - getAllStatsForPlayer(permittedScrimIds, name), - getAllKillsForPlayer(permittedScrimIds, name), - getAllMapWinratesForPlayer(permittedScrimIds, name), - getAllDeathsForPlayer(permittedScrimIds, name), - ]); + const result = await AppRuntime.runPromise( + Effect.all({ + allPlayerStats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), + allPlayerKills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), + mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), + allPlayerDeaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), + }, { concurrency: "unbounded" }) + ); + allPlayerStats = result.allPlayerStats; + allPlayerKills = result.allPlayerKills; + mapWinrates = result.mapWinrates; + allPlayerDeaths = result.allPlayerDeaths; } catch { return (
diff --git a/src/app/stats/compare/page.tsx b/src/app/stats/compare/page.tsx index 391028014..8e363b4aa 100644 --- a/src/app/stats/compare/page.tsx +++ b/src/app/stats/compare/page.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import type { Kill, PlayerStat, Scrim } from "@prisma/client"; import { useQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; diff --git a/src/components/charts/player/player-charts.tsx b/src/components/charts/player/player-charts.tsx index 2343b9884..a8c5b783c 100644 --- a/src/components/charts/player/player-charts.tsx +++ b/src/components/charts/player/player-charts.tsx @@ -6,7 +6,9 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { ScrimService } from "@/data/scrim"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; import { type NonMappableStat, type Stat, @@ -69,7 +71,11 @@ export async function PlayerCharts({ id, playerName }: Props) { const playerTeam = playerTeamName?.player_team === team1Name ? "Team1" : "Team2"; - const finalStats = await getPlayerFinalStats(id, playerName); + const finalStats = await AppRuntime.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(id, playerName)) + ) + ); const mostPlayedHero = finalStats.find( (stat) => diff --git a/src/components/charts/ult-timing-chart.tsx b/src/components/charts/ult-timing-chart.tsx index c50886eb1..8a333af7d 100644 --- a/src/components/charts/ult-timing-chart.tsx +++ b/src/components/charts/ult-timing-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { SubroleUltTiming } from "@/data/scrim-overview-dto"; +import type { SubroleUltTiming } from "@/data/scrim"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Bar, diff --git a/src/components/map/analysis/analysis-card.tsx b/src/components/map/analysis/analysis-card.tsx index 6e61bd02e..b932a7f52 100644 --- a/src/components/map/analysis/analysis-card.tsx +++ b/src/components/map/analysis/analysis-card.tsx @@ -23,7 +23,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { MapAbilityTimingAnalysis } from "@/data/scrim-ability-timing-dto"; +import type { MapAbilityTimingAnalysis } from "@/data/scrim"; import { ArrowRightLeft, Crosshair, diff --git a/src/components/map/analysis/analysis-sections.tsx b/src/components/map/analysis/analysis-sections.tsx index 12b8fad08..54f061176 100644 --- a/src/components/map/analysis/analysis-sections.tsx +++ b/src/components/map/analysis/analysis-sections.tsx @@ -22,7 +22,7 @@ import type { PlayerUltComparison, SubroleUltTiming, UltEfficiency, -} from "@/data/scrim-overview-dto"; +} from "@/data/scrim"; import { toHero, toKebabCase, toTimestamp } from "@/lib/utils"; import type { RoleName } from "@/types/heroes"; import type { Kill } from "@prisma/client"; diff --git a/src/components/map/analysis/efficiency-scorecard.tsx b/src/components/map/analysis/efficiency-scorecard.tsx index 29423ec6a..bda3f13f8 100644 --- a/src/components/map/analysis/efficiency-scorecard.tsx +++ b/src/components/map/analysis/efficiency-scorecard.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@/components/ui/badge"; -import type { UltEfficiency } from "@/data/scrim-overview-dto"; +import type { UltEfficiency } from "@/data/scrim"; import { cn } from "@/lib/utils"; type EfficiencyScorecardProps = { diff --git a/src/components/map/analysis/map-ability-timing-section.tsx b/src/components/map/analysis/map-ability-timing-section.tsx index f478e1523..83b34d5db 100644 --- a/src/components/map/analysis/map-ability-timing-section.tsx +++ b/src/components/map/analysis/map-ability-timing-section.tsx @@ -6,7 +6,7 @@ import type { AbilityTimingRow, FightPhase, MapAbilityTimingAnalysis, -} from "@/data/scrim-ability-timing-dto"; +} from "@/data/scrim"; import { cn, toHero } from "@/lib/utils"; import { ExclamationTriangleIcon, diff --git a/src/components/map/default-overview.tsx b/src/components/map/default-overview.tsx index 320d9b08f..348dae2f7 100644 --- a/src/components/map/default-overview.tsx +++ b/src/components/map/default-overview.tsx @@ -5,7 +5,15 @@ import { serializeCalibrationData, } from "@/data/killfeed-calibration-dto"; import { getRotationDeathAnalysis } from "@/data/rotation-death-dto"; -import { getMapAbilityTiming } from "@/data/scrim-ability-timing-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { + ScrimAbilityTimingService, + ScrimService, + assignPlayersToSubroles, + buildPlayerUltComparisons, +} from "@/data/scrim"; +import type { PlayerUltSummary, UltEfficiency } from "@/data/scrim"; import { positionalData } from "@/lib/flags"; import { Card, @@ -16,13 +24,6 @@ import { } from "@/components/ui/card"; import { CardIcon } from "@/components/ui/card-icon"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getFinalRoundStats } from "@/data/scrim-dto"; -import { - assignPlayersToSubroles, - buildPlayerUltComparisons, - type PlayerUltSummary, - type UltEfficiency, -} from "@/data/scrim-overview-dto"; import { filterUtilityRoundStartSwaps } from "@/data/team-hero-swap-dto"; import { getAjaxes } from "@/lib/analytics"; import { calculateMVPScoresForMap } from "@/lib/mvp-score"; @@ -68,7 +69,11 @@ export async function DefaultOverview({ orderBy: { round_number: "desc" }, }), prisma.matchStart.findFirst({ where: { MapDataId: mapDataId } }), - getFinalRoundStats(id), + AppRuntime.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getFinalRoundStats(id)) + ) + ), prisma.playerStat.findMany({ where: { MapDataId: mapDataId } }), groupKillsIntoFights(id), ]); @@ -253,7 +258,13 @@ export async function DefaultOverview({ const [abilityTimingAnalysis, rotationDeathAnalysis, killfeedCalibration] = await Promise.all([ - getMapAbilityTiming(id, team1Name, team2Name), + AppRuntime.runPromise( + ScrimAbilityTimingService.pipe( + Effect.flatMap((svc) => + svc.getMapAbilityTiming(id, team1Name, team2Name) + ) + ) + ), positionalEnabled ? getRotationDeathAnalysis(id) : null, positionalEnabled ? getKillfeedCalibration(id) : null, ]); diff --git a/src/components/map/player-card.tsx b/src/components/map/player-card.tsx index 20fdc733a..fe3345e2a 100644 --- a/src/components/map/player-card.tsx +++ b/src/components/map/player-card.tsx @@ -2,7 +2,9 @@ import { AllHeroes } from "@/components/player/all-heroes"; import { SpecificHero } from "@/components/player/specific-hero"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { ScrimService } from "@/data/scrim"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; import { getHeroNames, toHero } from "@/lib/utils"; import { getTranslations } from "next-intl/server"; @@ -12,7 +14,11 @@ export async function PlayerCard({ playerName, id }: Props) { const t = await getTranslations("mapPage.compare.playerCard"); const heroNames = await getHeroNames(); - const playerStatsByFinalRound = await getPlayerFinalStats(id, playerName); + const playerStatsByFinalRound = await AppRuntime.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(id, playerName)) + ) + ); const playerStats = playerStatsByFinalRound.filter( (stat) => stat.hero_time_played > 0 diff --git a/src/components/player/default-overview.tsx b/src/components/player/default-overview.tsx index 21ed0a742..6e865176c 100644 --- a/src/components/player/default-overview.tsx +++ b/src/components/player/default-overview.tsx @@ -7,7 +7,9 @@ import { CardTitle, } from "@/components/ui/card"; import { CardIcon } from "@/components/ui/card-icon"; -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { ScrimService } from "@/data/scrim"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; import { @@ -34,7 +36,13 @@ export async function DefaultOverview({ where: { MapDataId: mapDataId }, orderBy: { round_number: "desc" }, }), - getPlayerFinalStats(id, playerNameDecoded), + AppRuntime.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(id, playerNameDecoded) + ) + ) + ), groupKillsIntoFights(id), ]); diff --git a/src/components/scrim/fight-win-rate-chart.tsx b/src/components/scrim/fight-win-rate-chart.tsx index 7ff3d02f2..3068b2c69 100644 --- a/src/components/scrim/fight-win-rate-chart.tsx +++ b/src/components/scrim/fight-win-rate-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ScrimFightAnalysis } from "@/data/scrim-overview-dto"; +import type { ScrimFightAnalysis } from "@/data/scrim"; import { Bar, BarChart, diff --git a/src/components/scrim/player-performance-hover-chart.tsx b/src/components/scrim/player-performance-hover-chart.tsx index 398be1a46..4c4f984ed 100644 --- a/src/components/scrim/player-performance-hover-chart.tsx +++ b/src/components/scrim/player-performance-hover-chart.tsx @@ -7,7 +7,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import type { PlayerMapPerformance } from "@/data/scrim-overview-dto"; +import type { PlayerMapPerformance } from "@/data/scrim"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import { heroRoleMapping } from "@/types/heroes"; diff --git a/src/components/scrim/player-performance-table.tsx b/src/components/scrim/player-performance-table.tsx index 4177baa3f..3e5165992 100644 --- a/src/components/scrim/player-performance-table.tsx +++ b/src/components/scrim/player-performance-table.tsx @@ -7,7 +7,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { PlayerScrimPerformance } from "@/data/scrim-overview-dto"; +import type { PlayerScrimPerformance } from "@/data/scrim"; import { cn, format, toHero } from "@/lib/utils"; import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "@radix-ui/react-icons"; diff --git a/src/components/scrim/scrim-ability-timing-section.tsx b/src/components/scrim/scrim-ability-timing-section.tsx index 77efb0662..ee3b05058 100644 --- a/src/components/scrim/scrim-ability-timing-section.tsx +++ b/src/components/scrim/scrim-ability-timing-section.tsx @@ -5,7 +5,7 @@ import type { AbilityTimingOutlier, AbilityTimingRow, FightPhase, -} from "@/data/scrim-ability-timing-dto"; +} from "@/data/scrim"; import { cn, toHero } from "@/lib/utils"; import { ExclamationTriangleIcon, diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index 00463ef77..399666a1c 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -3,8 +3,10 @@ import { ScrimOverviewTabs } from "@/components/scrim/scrim-overview-tabs"; import { Badge } from "@/components/ui/badge"; import { CardContent, CardFooter, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import type { ScrimInsight } from "@/data/scrim-overview-dto"; -import { getScrimOverview } from "@/data/scrim-overview-dto"; +import type { ScrimInsight } from "@/data/scrim"; +import { ScrimOverviewService } from "@/data/scrim"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; import { format } from "@/lib/utils"; import { ArrowDownIcon, @@ -118,7 +120,11 @@ export async function ScrimOverviewCard({ scrimId, teamId, }: ScrimOverviewCardProps) { - const data = await getScrimOverview(scrimId, teamId); + const data = await AppRuntime.runPromise( + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId)) + ) + ); if (data.mapCount === 0 || data.teamPlayers.length === 0) { return null; diff --git a/src/components/scrim/scrim-overview-sections.tsx b/src/components/scrim/scrim-overview-sections.tsx index 7a834b666..13e1ab66e 100644 --- a/src/components/scrim/scrim-overview-sections.tsx +++ b/src/components/scrim/scrim-overview-sections.tsx @@ -14,7 +14,7 @@ import type { ScrimInsight, ScrimSwapAnalysis, ScrimUltAnalysis, -} from "@/data/scrim-overview-dto"; +} from "@/data/scrim"; import { ArrowRightLeft, Swords, Users, Zap } from "lucide-react"; type TeamPair = { diff --git a/src/components/scrim/scrim-overview-tabs.tsx b/src/components/scrim/scrim-overview-tabs.tsx index dd68326cd..995b61cb4 100644 --- a/src/components/scrim/scrim-overview-tabs.tsx +++ b/src/components/scrim/scrim-overview-tabs.tsx @@ -20,7 +20,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ScrimOverviewData } from "@/data/scrim-overview-dto"; +import type { ScrimOverviewData } from "@/data/scrim"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Activity, diff --git a/src/components/scrim/scrim-raw-stats-sections.tsx b/src/components/scrim/scrim-raw-stats-sections.tsx index 3e3042af6..02c6672e7 100644 --- a/src/components/scrim/scrim-raw-stats-sections.tsx +++ b/src/components/scrim/scrim-raw-stats-sections.tsx @@ -7,7 +7,7 @@ import type { ScrimFightAnalysis, ScrimSwapAnalysis, ScrimUltAnalysis, -} from "@/data/scrim-overview-dto"; +} from "@/data/scrim"; import { cn } from "@/lib/utils"; export function HighlightedPct({ diff --git a/src/components/scrim/ult-comparison-chart.tsx b/src/components/scrim/ult-comparison-chart.tsx index e7ea7793f..6ed39a890 100644 --- a/src/components/scrim/ult-comparison-chart.tsx +++ b/src/components/scrim/ult-comparison-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PlayerUltComparison } from "@/data/scrim-overview-dto"; +import type { PlayerUltComparison } from "@/data/scrim"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Bar, diff --git a/src/components/stats/compare/comparison-view.tsx b/src/components/stats/compare/comparison-view.tsx index 3c6558782..172e8ddce 100644 --- a/src/components/stats/compare/comparison-view.tsx +++ b/src/components/stats/compare/comparison-view.tsx @@ -22,7 +22,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import { cn } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; import type { Kill, PlayerStat, Scrim } from "@prisma/client"; diff --git a/src/components/stats/player/charts/map-wins-chart.tsx b/src/components/stats/player/charts/map-wins-chart.tsx index 2d993bb3b..7bf1f42e4 100644 --- a/src/components/stats/player/charts/map-wins-chart.tsx +++ b/src/components/stats/player/charts/map-wins-chart.tsx @@ -1,7 +1,7 @@ "use client"; import { CardContent, CardFooter } from "@/components/ui/card"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { cn, toKebabCase, toTitleCase, useMapNames } from "@/lib/utils"; import { type MapName, mapNameToMapTypeMapping } from "@/types/map"; diff --git a/src/components/stats/player/charts/wins-per-map-type.tsx b/src/components/stats/player/charts/wins-per-map-type.tsx index a2483e9fc..524f2dd10 100644 --- a/src/components/stats/player/charts/wins-per-map-type.tsx +++ b/src/components/stats/player/charts/wins-per-map-type.tsx @@ -1,7 +1,7 @@ "use client"; import { CardContent, CardFooter } from "@/components/ui/card"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { type MapName, mapNameToMapTypeMapping } from "@/types/map"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/player/range-picker.tsx b/src/components/stats/player/range-picker.tsx index b89e54967..7605f7c08 100644 --- a/src/components/stats/player/range-picker.tsx +++ b/src/components/stats/player/range-picker.tsx @@ -19,7 +19,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import { cn } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; import type { Kill, PlayerStat, Scrim } from "@prisma/client"; diff --git a/src/components/stats/player/statistics.tsx b/src/components/stats/player/statistics.tsx index c52e86457..b59530e8f 100644 --- a/src/components/stats/player/statistics.tsx +++ b/src/components/stats/player/statistics.tsx @@ -33,7 +33,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { Winrate } from "@/data/scrim-dto"; +import type { Winrate } from "@/data/scrim"; import type { NonMappableStat, Stat } from "@/lib/player-charts"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; diff --git a/src/lib/ai/formatters.ts b/src/lib/ai/formatters.ts index 746eaac8b..facd14351 100644 --- a/src/lib/ai/formatters.ts +++ b/src/lib/ai/formatters.ts @@ -1,4 +1,4 @@ -import type { ScrimOverviewData } from "@/data/scrim-overview-dto"; +import type { ScrimOverviewData } from "@/data/scrim/types"; function round(value: number, decimals = 1): number { const factor = 10 ** decimals; diff --git a/src/lib/ai/tools.ts b/src/lib/ai/tools.ts index 13f955377..25a6c30d9 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,12 +1,13 @@ import { getComparisonStats } from "@/data/comparison-dto"; import { getMapIntelligence } from "@/data/map-intelligence-dto"; import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; +import { AppRuntime } from "@/data/runtime"; import { - getScrimAbilityTiming, - getScrimFightTimelines, -} from "@/data/scrim-ability-timing-dto"; -import { getScrimOverview } from "@/data/scrim-overview-dto"; + ScrimAbilityTimingService, + ScrimOverviewService, +} from "@/data/scrim"; import { getTeamAbilityImpact } from "@/data/team-ability-impact-dto"; +import { Effect } from "effect"; import { getTeamFightStats } from "@/data/team-fight-stats-dto"; import { getHeroPoolAnalysis } from "@/data/team-hero-pool-dto"; import { @@ -266,7 +267,9 @@ export function buildTools(opts: { if (!scrim) { return { error: `Scrim ${scrimId} not found for team ${teamId}.` }; } - const data = await getScrimOverview(scrimId, teamId); + const data = await AppRuntime.runPromise( + ScrimOverviewService.pipe(Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId))) + ); return formatScrimOverview(data); }, }), @@ -297,7 +300,9 @@ export function buildTools(opts: { scrimIds .filter((id) => validIds.has(id)) .map(async (scrimId) => { - const data = await getScrimOverview(scrimId, teamId); + const data = await AppRuntime.runPromise( + ScrimOverviewService.pipe(Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId))) + ); return { scrimId, ...formatScrimOverview(data) }; }) ); @@ -552,7 +557,9 @@ export function buildTools(opts: { }); if (!scrim) return { error: `Scrim ${scrimId} not found.` }; - const data = await getScrimAbilityTiming(scrimId, teamId); + const data = await AppRuntime.runPromise( + ScrimAbilityTimingService.pipe(Effect.flatMap((svc) => svc.getScrimAbilityTiming(scrimId, teamId))) + ); if (data.rows.length === 0) { return { @@ -722,7 +729,9 @@ export function buildTools(opts: { }); if (!scrim) return { error: `Scrim ${scrimId} not found.` }; - const data = await getScrimFightTimelines(scrimId, teamId); + const data = await AppRuntime.runPromise( + ScrimAbilityTimingService.pipe(Effect.flatMap((svc) => svc.getScrimFightTimelines(scrimId, teamId))) + ); if (data.fights.length === 0) { return { diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index d5c53f899..51d8f7407 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,5 +1,7 @@ -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { Effect } from "effect"; import prisma from "@/lib/prisma"; import { groupKillsIntoFights, removeDuplicateRows, round } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; @@ -262,7 +264,9 @@ async function calculateAverageDuelWinrate(id: number, playerName: string) { export async function calculateXFactor(mapId: number, playerName: string) { const resolvedMapId = await resolveMapDataId(mapId); // Get the player's role - const playerStats = await getPlayerFinalStats(mapId, playerName); + const playerStats = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ); const mostPlayedHero = playerStats.sort( (a, b) => b.hero_time_played - a.hero_time_played )[0].player_hero; @@ -312,7 +316,9 @@ export async function calculateXFactor(mapId: number, playerName: string) { orderBy: { round_number: "desc" }, }); - const playerStatsByFinalRound = await getPlayerFinalStats(mapId, playerName); + const playerStatsByFinalRound = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ); const team = playerStatsByFinalRound[0]?.player_team; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8c7e3a92a..13b6acc63 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,8 +5,9 @@ import { authSignInCounter, rateLimitHitCounter, } from "@/lib/axiom/metrics"; -import { getScrim, getUserViewableScrims } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; +import { UserService } from "@/data/user"; import { email } from "@/lib/email"; import { createShortLink } from "@/lib/link-service"; import { Logger } from "@/lib/logger"; @@ -26,6 +27,7 @@ import { track } from "@vercel/analytics/server"; import { get } from "@vercel/edge-config"; import { kv } from "@vercel/kv"; import { createHash, randomBytes } from "crypto"; +import { Effect } from "effect"; import NextAuth, { type NextAuthConfig } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; import GithubProvider from "next-auth/providers/github"; @@ -251,14 +253,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth(config); export async function isAuthedToViewScrim(id: number) { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); // if user is admin return true if (user !== null && user?.role === $Enums.UserRole.ADMIN) { return true; } - const scrim = await getScrim(id); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(id))) + ); if (!scrim) return false; if (scrim.guestMode) return true; @@ -269,7 +275,9 @@ export async function isAuthedToViewScrim(id: number) { if (!user) return false; - const listOfViewableScrims = await getUserViewableScrims(user.id); + const listOfViewableScrims = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getUserViewableScrims(user.id))) + ); if (listOfViewableScrims.some((scrim) => scrim.id === id)) { return true; @@ -319,14 +327,18 @@ export async function isAuthedToViewScrim(id: number) { export async function isAuthedToViewMap(scrimId: number, mapId: number) { const session = await auth(); - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); // if user is admin return true if (user !== null && user?.role === $Enums.UserRole.ADMIN) { return true; } - const scrim = await getScrim(scrimId); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(scrimId))) + ); if (!scrim) return false; const scrimMaps = await prisma.map.findMany({ @@ -358,7 +370,9 @@ export async function isAuthedToViewTeam(id: number) { return false; } - const user = await getUser(session?.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); if (!user) { return false; diff --git a/src/lib/calculate-stats.ts b/src/lib/calculate-stats.ts index 57e7cf1cf..c07a17284 100644 --- a/src/lib/calculate-stats.ts +++ b/src/lib/calculate-stats.ts @@ -1,4 +1,6 @@ -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; +import { Effect } from "effect"; import { calculateDroughtTime, getAjaxes, @@ -27,13 +29,17 @@ export async function calculateStats(mapDataId: number, playerName: string) { playerMvpScore, mapMVP, ] = await Promise.all([ - getPlayerFinalStats(mapDataId, playerName), + AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapDataId, playerName))) + ), groupKillsIntoFights(mapDataId), prisma.roundEnd.findFirst({ where: { MapDataId: mapDataId }, orderBy: { round_number: "desc" }, }), - getPlayerFinalStats(mapDataId, playerName), + AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapDataId, playerName))) + ), getAverageUltChargeTime(mapDataId, playerName), getAverageTimeToUseUlt(mapDataId, playerName), getKillsPerUltimate(mapDataId, playerName), diff --git a/src/lib/mvp-score.ts b/src/lib/mvp-score.ts index ac59082fe..4ddee1438 100644 --- a/src/lib/mvp-score.ts +++ b/src/lib/mvp-score.ts @@ -1,5 +1,7 @@ -import { getFinalRoundStats, getPlayerFinalStats } from "@/data/scrim-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; import type { HeroName } from "@/types/heroes"; +import { Effect } from "effect"; import type { PlayerStat } from "@prisma/client"; import { compareMultipleStatsToDistribution, @@ -202,7 +204,9 @@ export async function calculateMVPScore({ minMaps = 5, minTimeSeconds = 300, }: CalculateMVPScoreParams): Promise { - const playerStats = await getPlayerFinalStats(mapId, playerName); + const playerStats = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ); if (playerStats.length === 0) { return null; @@ -254,7 +258,9 @@ export async function calculateMVPScoresForMap( minMaps = 5, minTimeSeconds = 300 ): Promise { - const allStats = await getFinalRoundStats(mapId); + const allStats = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStats(mapId))) + ); const uniquePlayerNames = new Set(allStats.map((stat) => stat.player_name)); diff --git a/src/lib/notes.ts b/src/lib/notes.ts index 33d0314a9..4eaede587 100644 --- a/src/lib/notes.ts +++ b/src/lib/notes.ts @@ -1,8 +1,10 @@ -import { getScrim } from "@/data/scrim-dto"; -import { getUser } from "@/data/user-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { Effect } from "effect"; import { unauthorized } from "next/navigation"; type UpsertNoteArgs = { @@ -15,7 +17,9 @@ export async function upsertNote(data: UpsertNoteArgs) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user?.email))) + ); if (!user) { Logger.error("User not found for session: ", session); @@ -84,14 +88,18 @@ export async function getNote(data: UpsertNoteArgs) { const session = await auth(); if (!session) unauthorized(); - const user = await getUser(session.user?.email); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user?.email))) + ); if (!user) { Logger.error("User not found for session: ", session); throw new Error("User not found"); } - const scrim = await getScrim(data.scrimId); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(data.scrimId))) + ); if (!scrim) { Logger.error(`Scrim with ID ${data.scrimId} not found`); From f9f6cdb6282432a60760272bf8acdfc0b093076d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:32:27 -0400 Subject: [PATCH 18/42] Migrate comparison-dto consumers to native Effect Switch 14 files from comparison-dto to ComparisonAggregationService via AppRuntime. Pure functions (aggregatePlayerStats, calculateTrends) redirected to @/data/comparison/computation. --- src/app/api/bot/compare/route.ts | 2 +- src/app/api/bot/profile/route.ts | 2 +- src/app/api/compare/maps/route.ts | 24 ++++++++++++------- src/app/api/compare/players/route.ts | 8 +++++-- src/app/api/compare/stats/route.ts | 12 ++++++---- src/components/compare/charts-view.tsx | 2 +- src/components/compare/comparison-content.tsx | 2 +- src/components/compare/consistency-view.tsx | 2 +- src/components/compare/delta-view.tsx | 2 +- .../compare/detailed-stats-view.tsx | 2 +- .../compare/impact-metrics-view.tsx | 2 +- src/components/compare/side-by-side-view.tsx | 2 +- src/components/compare/trends-view.tsx | 2 +- src/types/team-comparison.ts | 2 +- 14 files changed, 39 insertions(+), 27 deletions(-) diff --git a/src/app/api/bot/compare/route.ts b/src/app/api/bot/compare/route.ts index a043a569c..c438fc915 100644 --- a/src/app/api/bot/compare/route.ts +++ b/src/app/api/bot/compare/route.ts @@ -1,4 +1,4 @@ -import { aggregatePlayerStats } from "@/data/comparison-dto"; +import { aggregatePlayerStats } from "@/data/comparison/computation"; import { authenticateBotSecret } from "@/lib/bot-auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; diff --git a/src/app/api/bot/profile/route.ts b/src/app/api/bot/profile/route.ts index 17b8670a2..013ecf271 100644 --- a/src/app/api/bot/profile/route.ts +++ b/src/app/api/bot/profile/route.ts @@ -1,4 +1,4 @@ -import { aggregatePlayerStats, calculateTrends } from "@/data/comparison-dto"; +import { aggregatePlayerStats, calculateTrends } from "@/data/comparison/computation"; import { removeDuplicateRows } from "@/lib/utils"; import { authenticateBotSecret } from "@/lib/bot-auth"; import { Logger } from "@/lib/logger"; diff --git a/src/app/api/compare/maps/route.ts b/src/app/api/compare/maps/route.ts index 67b84c57b..071ddf96b 100644 --- a/src/app/api/compare/maps/route.ts +++ b/src/app/api/compare/maps/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { getAvailableMapsForComparison } from "@/data/comparison-dto"; +import { ComparisonAggregationService } from "@/data/comparison"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; @@ -89,14 +89,20 @@ export async function GET(request: NextRequest) { hero_count: heroes?.length ?? 0, }; - const maps = await getAvailableMapsForComparison({ - teamId, - playerName, - dateFrom, - dateTo, - mapType, - heroes, - }); + const maps = await AppRuntime.runPromise( + ComparisonAggregationService.pipe( + Effect.flatMap((svc) => + svc.getAvailableMapsForComparison({ + teamId, + playerName, + dateFrom, + dateTo, + mapType, + heroes, + }) + ) + ) + ); wideEvent.status_code = 200; wideEvent.outcome = "success"; diff --git a/src/app/api/compare/players/route.ts b/src/app/api/compare/players/route.ts index dd97efb16..84ed2a7f8 100644 --- a/src/app/api/compare/players/route.ts +++ b/src/app/api/compare/players/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { getTeamPlayers } from "@/data/comparison-dto"; +import { ComparisonAggregationService } from "@/data/comparison"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { NextRequest } from "next/server"; @@ -79,7 +79,11 @@ export async function GET(request: NextRequest) { } } - const players = await getTeamPlayers(teamId, mapIds); + const players = await AppRuntime.runPromise( + ComparisonAggregationService.pipe( + Effect.flatMap((svc) => svc.getTeamPlayers(teamId, mapIds)) + ) + ); wideEvent.status_code = 200; wideEvent.outcome = "success"; diff --git a/src/app/api/compare/stats/route.ts b/src/app/api/compare/stats/route.ts index 358036c32..ab1f6614b 100644 --- a/src/app/api/compare/stats/route.ts +++ b/src/app/api/compare/stats/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { getComparisonStats } from "@/data/comparison-dto"; +import { ComparisonAggregationService } from "@/data/comparison"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; @@ -115,10 +115,12 @@ export async function GET(request: NextRequest) { hero_count: heroes?.length ?? 0, }; - const comparisonStats = await getComparisonStats( - validMapIds.data, - validPlayerName.data, - heroes + const comparisonStats = await AppRuntime.runPromise( + ComparisonAggregationService.pipe( + Effect.flatMap((svc) => + svc.getComparisonStats(validMapIds.data, validPlayerName.data, heroes) + ) + ) ); wideEvent.status_code = 200; diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx index 1fe56cdfb..445583a06 100644 --- a/src/components/compare/charts-view.tsx +++ b/src/components/compare/charts-view.tsx @@ -9,7 +9,7 @@ import { ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import { useTranslations } from "next-intl"; import { Bar, diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index 2101a5179..384b9f21c 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -4,7 +4,7 @@ import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import type { HeroName } from "@/types/heroes"; import type { FormattedMapGroup } from "@/types/map-group"; import type { TeamComparisonStats } from "@/types/team-comparison"; diff --git a/src/components/compare/consistency-view.tsx b/src/components/compare/consistency-view.tsx index cc450e473..8d1732fcc 100644 --- a/src/components/compare/consistency-view.tsx +++ b/src/components/compare/consistency-view.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import { Activity, Target, TrendingUp, Zap } from "lucide-react"; import { Bar, diff --git a/src/components/compare/delta-view.tsx b/src/components/compare/delta-view.tsx index 666661dcd..629e81662 100644 --- a/src/components/compare/delta-view.tsx +++ b/src/components/compare/delta-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import { cn } from "@/lib/utils"; import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/compare/detailed-stats-view.tsx b/src/components/compare/detailed-stats-view.tsx index 3f1aee99f..08a25f617 100644 --- a/src/components/compare/detailed-stats-view.tsx +++ b/src/components/compare/detailed-stats-view.tsx @@ -15,7 +15,7 @@ import type { AggregatedStats, ComparisonStats, TrendsAnalysis, -} from "@/data/comparison-dto"; +} from "@/data/comparison"; import { Activity, Award, diff --git a/src/components/compare/impact-metrics-view.tsx b/src/components/compare/impact-metrics-view.tsx index f909eafec..47b76b0c5 100644 --- a/src/components/compare/impact-metrics-view.tsx +++ b/src/components/compare/impact-metrics-view.tsx @@ -3,7 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import type { ComparisonStats, TrendsAnalysis } from "@/data/comparison-dto"; +import type { ComparisonStats, TrendsAnalysis } from "@/data/comparison"; import { Award, Crosshair, diff --git a/src/components/compare/side-by-side-view.tsx b/src/components/compare/side-by-side-view.tsx index 68ab0b3b7..abeb404e2 100644 --- a/src/components/compare/side-by-side-view.tsx +++ b/src/components/compare/side-by-side-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/compare/trends-view.tsx b/src/components/compare/trends-view.tsx index fbfabf607..3aa537206 100644 --- a/src/components/compare/trends-view.tsx +++ b/src/components/compare/trends-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison-dto"; +import type { ComparisonStats } from "@/data/comparison"; import { cn } from "@/lib/utils"; import { AlertTriangle, diff --git a/src/types/team-comparison.ts b/src/types/team-comparison.ts index badebf999..15f0dc16d 100644 --- a/src/types/team-comparison.ts +++ b/src/types/team-comparison.ts @@ -1,4 +1,4 @@ -import type { AggregatedStats } from "@/data/comparison-dto"; +import type { AggregatedStats } from "@/data/comparison"; /** * Team comparison result showing both teams' aggregated stats From 58ba3c3ad8831f7d6c66e677c8b5db792003161a Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:32:34 -0400 Subject: [PATCH 19/42] Migrate scouting-dto and opponent-strength-dto consumers to native Effect Switch 6 files from scouting-dto and opponent-strength-dto to ScoutingService and OpponentStrengthService via AppRuntime. --- src/app/[team]/scrim/[scrimId]/edit/page.tsx | 6 ++-- src/app/api/scouting/get-teams/route.ts | 8 +++-- src/app/scouting/page.tsx | 8 +++-- src/app/scouting/team/[teamAbbr]/page.tsx | 31 ++++++++++++++------ src/app/team/[teamId]/page.tsx | 6 ++-- src/lib/insights.ts | 2 +- 6 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 7dcb46d49..800687c76 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -2,10 +2,10 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { DangerZone } from "@/components/scrim/danger-zone"; import { EditScrimForm } from "@/components/scrim/edit-scrim-form"; import { Link } from "@/components/ui/link"; -import { getScoutingTeams } from "@/data/scouting-dto"; import { ScrimService } from "@/data/scrim"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; +import { ScoutingService } from "@/data/scouting"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; @@ -37,7 +37,9 @@ export default async function EditScrimPage( ) ) ), - getScoutingTeams(), + AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) + ), scoutingTool(), ] ); diff --git a/src/app/api/scouting/get-teams/route.ts b/src/app/api/scouting/get-teams/route.ts index e54c3b66d..942e71a1c 100644 --- a/src/app/api/scouting/get-teams/route.ts +++ b/src/app/api/scouting/get-teams/route.ts @@ -1,4 +1,6 @@ -import { getScoutingTeams } from "@/data/scouting-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScoutingService } from "@/data/scouting"; +import { Effect } from "effect"; import { auth } from "@/lib/auth"; import { unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; @@ -14,7 +16,9 @@ export async function GET() { unauthorized(); } - const scoutingTeams = await getScoutingTeams(); + const scoutingTeams = await AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) + ); const teams = scoutingTeams.map((t) => ({ abbreviation: t.abbreviation, diff --git a/src/app/scouting/page.tsx b/src/app/scouting/page.tsx index 653c35410..bd4c339a5 100644 --- a/src/app/scouting/page.tsx +++ b/src/app/scouting/page.tsx @@ -1,5 +1,7 @@ import { TeamSearch } from "@/components/scouting/team-search"; -import { getScoutingTeams } from "@/data/scouting-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScoutingService } from "@/data/scouting"; +import { Effect } from "effect"; import { scoutingTool } from "@/lib/flags"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -9,7 +11,9 @@ export default async function ScoutingPage() { if (!scoutingEnabled) notFound(); const t = await getTranslations("scoutingPage"); - const teams = await getScoutingTeams(); + const teams = await AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) + ); return (
diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index 45250c4f9..37466b0e6 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -10,12 +10,10 @@ import { TeamOverviewEnhanced } from "@/components/scouting/team-overview-enhanc import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getHeroBanIntelligence } from "@/data/hero-ban-intelligence-dto"; import { getMapIntelligence } from "@/data/map-intelligence-dto"; -import { - getTeamStrengthRating, - getTeamStrengthPercentile, -} from "@/data/opponent-strength-dto"; import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; -import { getScoutingTeamProfile } from "@/data/scouting-dto"; +import { AppRuntime } from "@/data/runtime"; +import { OpponentStrengthService, ScoutingService } from "@/data/scouting"; +import { Effect } from "effect"; import { auth } from "@/lib/auth"; import { resolveDataAvailability } from "@/lib/data-availability"; import { scoutingTool } from "@/lib/flags"; @@ -86,7 +84,11 @@ export default async function ScoutingTeamPage( const teamAbbr = decodeURIComponent(params.teamAbbr); const t = await getTranslations("scoutingPage.team"); - const profile = await getScoutingTeamProfile(teamAbbr); + const profile = await AppRuntime.runPromise( + ScoutingService.pipe( + Effect.flatMap((svc) => svc.getScoutingTeamProfile(teamAbbr)) + ) + ); if (!profile) notFound(); const { overview } = profile; @@ -95,10 +97,21 @@ export default async function ScoutingTeamPage( const userTeamId = resolveScoutForTeamId(searchParams.scoutFor, userTeams); const hasUserTeamLink = userTeamId !== null; - const [strengthRating, strengthPercentile, dataAvailability] = + const [{ strengthRating, strengthPercentile }, dataAvailability] = await Promise.all([ - getTeamStrengthRating(teamAbbr), - getTeamStrengthPercentile(teamAbbr), + AppRuntime.runPromise( + Effect.all( + { + strengthRating: OpponentStrengthService.pipe( + Effect.flatMap((svc) => svc.getTeamStrengthRating(teamAbbr)) + ), + strengthPercentile: OpponentStrengthService.pipe( + Effect.flatMap((svc) => svc.getTeamStrengthPercentile(teamAbbr)) + ), + }, + { concurrency: "unbounded" } + ) + ), resolveDataAvailability(teamAbbr, userTeamId), ]); diff --git a/src/app/team/[teamId]/page.tsx b/src/app/team/[teamId]/page.tsx index e02cee477..3a0a552bf 100644 --- a/src/app/team/[teamId]/page.tsx +++ b/src/app/team/[teamId]/page.tsx @@ -10,9 +10,9 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { getScoutingTeams } from "@/data/scouting-dto"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; +import { ScoutingService } from "@/data/scouting"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; @@ -98,7 +98,9 @@ export default async function Team( }), prisma.teamManager.findMany({ where: { teamId } }), scoutingTool(), - getScoutingTeams(), + AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) + ), ]); const teamMembers = teamMembersData ?? { users: [] }; diff --git a/src/lib/insights.ts b/src/lib/insights.ts index b06b60ef0..5d8b54cea 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -1,6 +1,6 @@ import type { HeroBanIntelligence } from "@/data/hero-ban-intelligence-dto"; import type { MapIntelligence } from "@/data/map-intelligence-dto"; -import type { TeamStrengthRating } from "@/data/opponent-strength-dto"; +import type { TeamStrengthRating } from "@/data/scouting"; import type { PlayerIntelligence } from "@/data/player-intelligence-dto"; import { assessConfidence, From 3347ad91420c768e053fc8eb2203632872e27aaf Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 16:32:44 -0400 Subject: [PATCH 20/42] Migrate ai/tools.ts to native Effect services Switch remaining DTO imports in AI tool definitions to use ScrimOverviewService, ScrimAbilityTimingService, and ComparisonAggregationService via AppRuntime. --- src/lib/ai/tools.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/lib/ai/tools.ts b/src/lib/ai/tools.ts index 25a6c30d9..ba83d3928 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,4 +1,4 @@ -import { getComparisonStats } from "@/data/comparison-dto"; +import { ComparisonAggregationService } from "@/data/comparison"; import { getMapIntelligence } from "@/data/map-intelligence-dto"; import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; import { AppRuntime } from "@/data/runtime"; @@ -386,10 +386,16 @@ export function buildTools(opts: { if (!hasAccess) { return { error: "You don't have access to the requested maps." }; } - const data = await getComparisonStats( - mapIds, - playerName, - heroes as HeroName[] | undefined + const data = await AppRuntime.runPromise( + ComparisonAggregationService.pipe( + Effect.flatMap((svc) => + svc.getComparisonStats( + mapIds, + playerName, + heroes as HeroName[] | undefined + ) + ) + ) ); return { playerName: data.playerName, From 8d10005855a014a7f651ea40e8ae212f3abc67b1 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:03:07 -0400 Subject: [PATCH 21/42] Split type exports from service exports for client component safety Add "server-only" to all domain barrel index.ts files so only server components can import services. Extract all client-needed types from service files into types.ts files (team: 40+ types, scrim: Winrate + PlayerUltSummary). Client components now import types from @/data/{domain}/types instead of the barrel. Service files re-import types from types.ts to maintain backward compatibility for server consumers importing from the barrel. --- src/components/charts/ult-timing-chart.tsx | 2 +- src/components/compare/charts-view.tsx | 2 +- src/components/compare/comparison-content.tsx | 2 +- src/components/compare/consistency-view.tsx | 2 +- src/components/compare/delta-view.tsx | 2 +- .../compare/detailed-stats-view.tsx | 2 +- .../compare/impact-metrics-view.tsx | 2 +- src/components/compare/side-by-side-view.tsx | 2 +- src/components/compare/trends-view.tsx | 2 +- src/components/map/analysis/analysis-card.tsx | 2 +- .../map/analysis/analysis-sections.tsx | 2 +- .../map/analysis/efficiency-scorecard.tsx | 2 +- .../analysis/map-ability-timing-section.tsx | 2 +- src/components/map/default-overview.tsx | 5 +- src/components/scouting/hero-ban-chart.tsx | 2 +- .../scouting/map-performance-chart.tsx | 2 +- .../scouting/match-history-table.tsx | 2 +- .../scouting/scouting-recommendations.tsx | 2 +- .../scouting/team-overview-cards.tsx | 2 +- .../scouting/team-overview-enhanced.tsx | 2 +- src/components/scouting/team-search.tsx | 2 +- src/components/scrim/fight-win-rate-chart.tsx | 2 +- .../scrim/player-performance-hover-chart.tsx | 2 +- .../scrim/player-performance-table.tsx | 2 +- .../scrim/scrim-ability-timing-section.tsx | 2 +- src/components/scrim/scrim-overview-card.tsx | 2 +- .../scrim/scrim-overview-sections.tsx | 2 +- src/components/scrim/scrim-overview-tabs.tsx | 2 +- .../scrim/scrim-raw-stats-sections.tsx | 2 +- src/components/scrim/ult-comparison-chart.tsx | 2 +- .../stats/compare/comparison-view.tsx | 2 +- .../stats/player/charts/map-wins-chart.tsx | 2 +- .../stats/player/charts/wins-per-map-type.tsx | 2 +- src/components/stats/player/range-picker.tsx | 2 +- src/components/stats/player/statistics.tsx | 2 +- src/components/team/team-settings-form.tsx | 2 +- src/data/__tests__/migration-parity.test.ts | 1808 +++++++++++++++++ src/data/admin/index.ts | 2 + src/data/comparison/index.ts | 2 + src/data/hero/index.ts | 2 + src/data/intelligence/index.ts | 2 + src/data/map/heatmap/index.ts | 2 + src/data/map/index.ts | 2 + src/data/map/killfeed/index.ts | 2 + src/data/map/replay/index.ts | 2 + src/data/player/index.ts | 2 + src/data/runtime.ts | 2 + src/data/scouting/index.ts | 2 + src/data/scrim/index.ts | 2 + src/data/scrim/overview-service.ts | 12 +- src/data/scrim/scrim-service.ts | 3 +- src/data/scrim/types.ts | 5 +- src/data/team/ability-impact-service.ts | 37 +- src/data/team/ban-impact-service.ts | 50 +- src/data/team/fight-stats-service.ts | 32 +- src/data/team/hero-pool-service.ts | 84 +- src/data/team/hero-swap-service.ts | 89 +- src/data/team/index.ts | 155 +- src/data/team/map-mode-service.ts | 24 +- src/data/team/matchup-service.ts | 48 +- src/data/team/quick-wins-service.ts | 18 +- src/data/team/role-stats-service.ts | 66 +- src/data/team/stats-service.ts | 19 +- src/data/team/trends-service.ts | 53 +- src/data/team/types.ts | 521 +++++ src/data/team/ult-service.ts | 74 +- src/data/tournament/index.ts | 2 + src/data/user/index.ts | 2 + 68 files changed, 2607 insertions(+), 594 deletions(-) create mode 100644 src/data/__tests__/migration-parity.test.ts diff --git a/src/components/charts/ult-timing-chart.tsx b/src/components/charts/ult-timing-chart.tsx index 8a333af7d..519275d48 100644 --- a/src/components/charts/ult-timing-chart.tsx +++ b/src/components/charts/ult-timing-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { SubroleUltTiming } from "@/data/scrim"; +import type { SubroleUltTiming } from "@/data/scrim/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Bar, diff --git a/src/components/compare/charts-view.tsx b/src/components/compare/charts-view.tsx index 445583a06..443f7089d 100644 --- a/src/components/compare/charts-view.tsx +++ b/src/components/compare/charts-view.tsx @@ -9,7 +9,7 @@ import { ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import { useTranslations } from "next-intl"; import { Bar, diff --git a/src/components/compare/comparison-content.tsx b/src/components/compare/comparison-content.tsx index 384b9f21c..c84383729 100644 --- a/src/components/compare/comparison-content.tsx +++ b/src/components/compare/comparison-content.tsx @@ -4,7 +4,7 @@ import { Card } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import type { HeroName } from "@/types/heroes"; import type { FormattedMapGroup } from "@/types/map-group"; import type { TeamComparisonStats } from "@/types/team-comparison"; diff --git a/src/components/compare/consistency-view.tsx b/src/components/compare/consistency-view.tsx index 8d1732fcc..aa8418924 100644 --- a/src/components/compare/consistency-view.tsx +++ b/src/components/compare/consistency-view.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import { Activity, Target, TrendingUp, Zap } from "lucide-react"; import { Bar, diff --git a/src/components/compare/delta-view.tsx b/src/components/compare/delta-view.tsx index 629e81662..6870eec58 100644 --- a/src/components/compare/delta-view.tsx +++ b/src/components/compare/delta-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import { cn } from "@/lib/utils"; import { ArrowDown, ArrowUp, TrendingDown, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/compare/detailed-stats-view.tsx b/src/components/compare/detailed-stats-view.tsx index 08a25f617..bd25fc2ab 100644 --- a/src/components/compare/detailed-stats-view.tsx +++ b/src/components/compare/detailed-stats-view.tsx @@ -15,7 +15,7 @@ import type { AggregatedStats, ComparisonStats, TrendsAnalysis, -} from "@/data/comparison"; +} from "@/data/comparison/types"; import { Activity, Award, diff --git a/src/components/compare/impact-metrics-view.tsx b/src/components/compare/impact-metrics-view.tsx index 47b76b0c5..131801043 100644 --- a/src/components/compare/impact-metrics-view.tsx +++ b/src/components/compare/impact-metrics-view.tsx @@ -3,7 +3,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import type { ComparisonStats, TrendsAnalysis } from "@/data/comparison"; +import type { ComparisonStats, TrendsAnalysis } from "@/data/comparison/types"; import { Award, Crosshair, diff --git a/src/components/compare/side-by-side-view.tsx b/src/components/compare/side-by-side-view.tsx index abeb404e2..47f7bc0fa 100644 --- a/src/components/compare/side-by-side-view.tsx +++ b/src/components/compare/side-by-side-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import { ArrowDown, ArrowUp, Minus } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/compare/trends-view.tsx b/src/components/compare/trends-view.tsx index 3aa537206..32a7581f8 100644 --- a/src/components/compare/trends-view.tsx +++ b/src/components/compare/trends-view.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { ComparisonStats } from "@/data/comparison"; +import type { ComparisonStats } from "@/data/comparison/types"; import { cn } from "@/lib/utils"; import { AlertTriangle, diff --git a/src/components/map/analysis/analysis-card.tsx b/src/components/map/analysis/analysis-card.tsx index b932a7f52..62c7cf56f 100644 --- a/src/components/map/analysis/analysis-card.tsx +++ b/src/components/map/analysis/analysis-card.tsx @@ -23,7 +23,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { MapAbilityTimingAnalysis } from "@/data/scrim"; +import type { MapAbilityTimingAnalysis } from "@/data/scrim/types"; import { ArrowRightLeft, Crosshair, diff --git a/src/components/map/analysis/analysis-sections.tsx b/src/components/map/analysis/analysis-sections.tsx index 54f061176..69ced48a6 100644 --- a/src/components/map/analysis/analysis-sections.tsx +++ b/src/components/map/analysis/analysis-sections.tsx @@ -22,7 +22,7 @@ import type { PlayerUltComparison, SubroleUltTiming, UltEfficiency, -} from "@/data/scrim"; +} from "@/data/scrim/types"; import { toHero, toKebabCase, toTimestamp } from "@/lib/utils"; import type { RoleName } from "@/types/heroes"; import type { Kill } from "@prisma/client"; diff --git a/src/components/map/analysis/efficiency-scorecard.tsx b/src/components/map/analysis/efficiency-scorecard.tsx index bda3f13f8..0e4dfa309 100644 --- a/src/components/map/analysis/efficiency-scorecard.tsx +++ b/src/components/map/analysis/efficiency-scorecard.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@/components/ui/badge"; -import type { UltEfficiency } from "@/data/scrim"; +import type { UltEfficiency } from "@/data/scrim/types"; import { cn } from "@/lib/utils"; type EfficiencyScorecardProps = { diff --git a/src/components/map/analysis/map-ability-timing-section.tsx b/src/components/map/analysis/map-ability-timing-section.tsx index 83b34d5db..945c3762b 100644 --- a/src/components/map/analysis/map-ability-timing-section.tsx +++ b/src/components/map/analysis/map-ability-timing-section.tsx @@ -6,7 +6,7 @@ import type { AbilityTimingRow, FightPhase, MapAbilityTimingAnalysis, -} from "@/data/scrim"; +} from "@/data/scrim/types"; import { cn, toHero } from "@/lib/utils"; import { ExclamationTriangleIcon, diff --git a/src/components/map/default-overview.tsx b/src/components/map/default-overview.tsx index 348dae2f7..9600cbd44 100644 --- a/src/components/map/default-overview.tsx +++ b/src/components/map/default-overview.tsx @@ -10,10 +10,9 @@ import { AppRuntime } from "@/data/runtime"; import { ScrimAbilityTimingService, ScrimService, - assignPlayersToSubroles, - buildPlayerUltComparisons, } from "@/data/scrim"; -import type { PlayerUltSummary, UltEfficiency } from "@/data/scrim"; +import { assignPlayersToSubroles, buildPlayerUltComparisons } from "@/data/scrim/ult-helpers"; +import type { PlayerUltSummary, UltEfficiency } from "@/data/scrim/types"; import { positionalData } from "@/lib/flags"; import { Card, diff --git a/src/components/scouting/hero-ban-chart.tsx b/src/components/scouting/hero-ban-chart.tsx index 5cb81cd5b..6e32e135f 100644 --- a/src/components/scouting/hero-ban-chart.tsx +++ b/src/components/scouting/hero-ban-chart.tsx @@ -13,7 +13,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { ScoutingHeroBans } from "@/data/scouting"; +import type { ScoutingHeroBans } from "@/data/scouting/types"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/scouting/map-performance-chart.tsx b/src/components/scouting/map-performance-chart.tsx index 02925ded1..f7b19c45a 100644 --- a/src/components/scouting/map-performance-chart.tsx +++ b/src/components/scouting/map-performance-chart.tsx @@ -14,7 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { ScoutingMapAnalysis } from "@/data/scouting"; +import type { ScoutingMapAnalysis } from "@/data/scouting/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/scouting/match-history-table.tsx b/src/components/scouting/match-history-table.tsx index 284799a96..66e52da60 100644 --- a/src/components/scouting/match-history-table.tsx +++ b/src/components/scouting/match-history-table.tsx @@ -18,7 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { ScoutingMatchHistoryEntry } from "@/data/scouting"; +import type { ScoutingMatchHistoryEntry } from "@/data/scouting/types"; import { useTranslations } from "next-intl"; import { useState } from "react"; diff --git a/src/components/scouting/scouting-recommendations.tsx b/src/components/scouting/scouting-recommendations.tsx index 59e135bc3..5aa51c86e 100644 --- a/src/components/scouting/scouting-recommendations.tsx +++ b/src/components/scouting/scouting-recommendations.tsx @@ -11,7 +11,7 @@ import { import type { ScoutingRecommendation, ScoutingRecommendations as ScoutingRecommendationsType, -} from "@/data/scouting"; +} from "@/data/scouting/types"; import { assessConfidence } from "@/lib/confidence"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-overview-cards.tsx b/src/components/scouting/team-overview-cards.tsx index eb9308f1f..2592c1414 100644 --- a/src/components/scouting/team-overview-cards.tsx +++ b/src/components/scouting/team-overview-cards.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { MatchResult, ScoutingTeamOverview } from "@/data/scouting"; +import type { MatchResult, ScoutingTeamOverview } from "@/data/scouting/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-overview-enhanced.tsx b/src/components/scouting/team-overview-enhanced.tsx index 311a5cbd1..498714fa7 100644 --- a/src/components/scouting/team-overview-enhanced.tsx +++ b/src/components/scouting/team-overview-enhanced.tsx @@ -14,7 +14,7 @@ import type { ScoutingMatchHistoryEntry, ScoutingTeamOverview, TeamStrengthRating, -} from "@/data/scouting"; +} from "@/data/scouting/types"; import { assessConfidence } from "@/lib/confidence"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/team-search.tsx b/src/components/scouting/team-search.tsx index 5ac4f1520..a8f5528ba 100644 --- a/src/components/scouting/team-search.tsx +++ b/src/components/scouting/team-search.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ScoutingTeam } from "@/data/scouting"; +import type { ScoutingTeam } from "@/data/scouting/types"; import { cn } from "@/lib/utils"; import Fuse from "fuse.js"; import { Search } from "lucide-react"; diff --git a/src/components/scrim/fight-win-rate-chart.tsx b/src/components/scrim/fight-win-rate-chart.tsx index 3068b2c69..a54b272a1 100644 --- a/src/components/scrim/fight-win-rate-chart.tsx +++ b/src/components/scrim/fight-win-rate-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ScrimFightAnalysis } from "@/data/scrim"; +import type { ScrimFightAnalysis } from "@/data/scrim/types"; import { Bar, BarChart, diff --git a/src/components/scrim/player-performance-hover-chart.tsx b/src/components/scrim/player-performance-hover-chart.tsx index 4c4f984ed..01763f88b 100644 --- a/src/components/scrim/player-performance-hover-chart.tsx +++ b/src/components/scrim/player-performance-hover-chart.tsx @@ -7,7 +7,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import type { PlayerMapPerformance } from "@/data/scrim"; +import type { PlayerMapPerformance } from "@/data/scrim/types"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import { heroRoleMapping } from "@/types/heroes"; diff --git a/src/components/scrim/player-performance-table.tsx b/src/components/scrim/player-performance-table.tsx index 3e5165992..77198cb87 100644 --- a/src/components/scrim/player-performance-table.tsx +++ b/src/components/scrim/player-performance-table.tsx @@ -7,7 +7,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { PlayerScrimPerformance } from "@/data/scrim"; +import type { PlayerScrimPerformance } from "@/data/scrim/types"; import { cn, format, toHero } from "@/lib/utils"; import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from "@radix-ui/react-icons"; diff --git a/src/components/scrim/scrim-ability-timing-section.tsx b/src/components/scrim/scrim-ability-timing-section.tsx index ee3b05058..238ffdeb8 100644 --- a/src/components/scrim/scrim-ability-timing-section.tsx +++ b/src/components/scrim/scrim-ability-timing-section.tsx @@ -5,7 +5,7 @@ import type { AbilityTimingOutlier, AbilityTimingRow, FightPhase, -} from "@/data/scrim"; +} from "@/data/scrim/types"; import { cn, toHero } from "@/lib/utils"; import { ExclamationTriangleIcon, diff --git a/src/components/scrim/scrim-overview-card.tsx b/src/components/scrim/scrim-overview-card.tsx index 399666a1c..a352e90f1 100644 --- a/src/components/scrim/scrim-overview-card.tsx +++ b/src/components/scrim/scrim-overview-card.tsx @@ -3,7 +3,7 @@ import { ScrimOverviewTabs } from "@/components/scrim/scrim-overview-tabs"; import { Badge } from "@/components/ui/badge"; import { CardContent, CardFooter, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import type { ScrimInsight } from "@/data/scrim"; +import type { ScrimInsight } from "@/data/scrim/types"; import { ScrimOverviewService } from "@/data/scrim"; import { AppRuntime } from "@/data/runtime"; import { Effect } from "effect"; diff --git a/src/components/scrim/scrim-overview-sections.tsx b/src/components/scrim/scrim-overview-sections.tsx index 13e1ab66e..8284c9933 100644 --- a/src/components/scrim/scrim-overview-sections.tsx +++ b/src/components/scrim/scrim-overview-sections.tsx @@ -14,7 +14,7 @@ import type { ScrimInsight, ScrimSwapAnalysis, ScrimUltAnalysis, -} from "@/data/scrim"; +} from "@/data/scrim/types"; import { ArrowRightLeft, Swords, Users, Zap } from "lucide-react"; type TeamPair = { diff --git a/src/components/scrim/scrim-overview-tabs.tsx b/src/components/scrim/scrim-overview-tabs.tsx index 995b61cb4..9d11b2720 100644 --- a/src/components/scrim/scrim-overview-tabs.tsx +++ b/src/components/scrim/scrim-overview-tabs.tsx @@ -20,7 +20,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { ScrimOverviewData } from "@/data/scrim"; +import type { ScrimOverviewData } from "@/data/scrim/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Activity, diff --git a/src/components/scrim/scrim-raw-stats-sections.tsx b/src/components/scrim/scrim-raw-stats-sections.tsx index 02c6672e7..b4a6ab731 100644 --- a/src/components/scrim/scrim-raw-stats-sections.tsx +++ b/src/components/scrim/scrim-raw-stats-sections.tsx @@ -7,7 +7,7 @@ import type { ScrimFightAnalysis, ScrimSwapAnalysis, ScrimUltAnalysis, -} from "@/data/scrim"; +} from "@/data/scrim/types"; import { cn } from "@/lib/utils"; export function HighlightedPct({ diff --git a/src/components/scrim/ult-comparison-chart.tsx b/src/components/scrim/ult-comparison-chart.tsx index 6ed39a890..526a0b3c2 100644 --- a/src/components/scrim/ult-comparison-chart.tsx +++ b/src/components/scrim/ult-comparison-chart.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PlayerUltComparison } from "@/data/scrim"; +import type { PlayerUltComparison } from "@/data/scrim/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { Bar, diff --git a/src/components/stats/compare/comparison-view.tsx b/src/components/stats/compare/comparison-view.tsx index 172e8ddce..5fcc1aa98 100644 --- a/src/components/stats/compare/comparison-view.tsx +++ b/src/components/stats/compare/comparison-view.tsx @@ -22,7 +22,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { Winrate } from "@/data/scrim"; +import type { Winrate } from "@/data/scrim/types"; import { cn } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; import type { Kill, PlayerStat, Scrim } from "@prisma/client"; diff --git a/src/components/stats/player/charts/map-wins-chart.tsx b/src/components/stats/player/charts/map-wins-chart.tsx index 7bf1f42e4..0cd7b1a10 100644 --- a/src/components/stats/player/charts/map-wins-chart.tsx +++ b/src/components/stats/player/charts/map-wins-chart.tsx @@ -1,7 +1,7 @@ "use client"; import { CardContent, CardFooter } from "@/components/ui/card"; -import type { Winrate } from "@/data/scrim"; +import type { Winrate } from "@/data/scrim/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { cn, toKebabCase, toTitleCase, useMapNames } from "@/lib/utils"; import { type MapName, mapNameToMapTypeMapping } from "@/types/map"; diff --git a/src/components/stats/player/charts/wins-per-map-type.tsx b/src/components/stats/player/charts/wins-per-map-type.tsx index 524f2dd10..df15e10f8 100644 --- a/src/components/stats/player/charts/wins-per-map-type.tsx +++ b/src/components/stats/player/charts/wins-per-map-type.tsx @@ -1,7 +1,7 @@ "use client"; import { CardContent, CardFooter } from "@/components/ui/card"; -import type { Winrate } from "@/data/scrim"; +import type { Winrate } from "@/data/scrim/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { type MapName, mapNameToMapTypeMapping } from "@/types/map"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/player/range-picker.tsx b/src/components/stats/player/range-picker.tsx index 7605f7c08..f8e05e3a4 100644 --- a/src/components/stats/player/range-picker.tsx +++ b/src/components/stats/player/range-picker.tsx @@ -19,7 +19,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { Winrate } from "@/data/scrim"; +import type { Winrate } from "@/data/scrim/types"; import { cn } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; import type { Kill, PlayerStat, Scrim } from "@prisma/client"; diff --git a/src/components/stats/player/statistics.tsx b/src/components/stats/player/statistics.tsx index b59530e8f..69b414154 100644 --- a/src/components/stats/player/statistics.tsx +++ b/src/components/stats/player/statistics.tsx @@ -33,7 +33,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { Winrate } from "@/data/scrim"; +import type { Winrate } from "@/data/scrim/types"; import type { NonMappableStat, Stat } from "@/lib/player-charts"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; diff --git a/src/components/team/team-settings-form.tsx b/src/components/team/team-settings-form.tsx index 0c0655ee4..4e78d1fd3 100644 --- a/src/components/team/team-settings-form.tsx +++ b/src/components/team/team-settings-form.tsx @@ -31,7 +31,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { ScoutingTeam } from "@/data/scouting"; +import type { ScoutingTeam } from "@/data/scouting/types"; import { ClientOnly } from "@/lib/client-only"; import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; diff --git a/src/data/__tests__/migration-parity.test.ts b/src/data/__tests__/migration-parity.test.ts new file mode 100644 index 000000000..28da2b797 --- /dev/null +++ b/src/data/__tests__/migration-parity.test.ts @@ -0,0 +1,1808 @@ +/* oxlint-disable typescript-eslint/consistent-type-assertions, typescript-eslint/require-await */ +/** + * Migration parity tests: verify new Effect services produce identical results + * to the old DTO implementations given the same Prisma mock data. + * + * Strategy: + * - Mock Prisma with controlled fixture data + * - Call the old DTO function directly (they use the same Prisma singleton) + * - Run the new Effect service method via Effect.runPromise with a test layer + * - Compare results structurally + */ + +import { Effect, Layer, Logger } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/prisma"); +vi.mock("server-only", () => ({})); +vi.mock("react", () => ({ + cache: unknown>(fn: T) => fn, +})); +vi.mock("@/instrumentation", async () => { + const { Layer } = await import("effect"); + return { EffectObservabilityLive: Layer.empty }; +}); +vi.mock("@/lib/axiom/server", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import prisma from "@/lib/__mocks__/prisma"; + +// --------------------------------------------------------------------------- +// User domain +// --------------------------------------------------------------------------- +import { getUser, getTeamsWithPerms } from "@/data/user-dto"; +import { UserService, UserServiceLive } from "@/data/user/service"; + +const TestLogger = Logger.none; + +describe("User service parity", () => { + const mockUser = { + id: "user-1", + name: "Test User", + email: "test@example.com", + emailVerified: null, + image: null, + role: "USER" as const, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + }; + + beforeEach(() => { + prisma.user.findFirst.mockResolvedValue(mockUser); + prisma.team.findMany.mockResolvedValue([]); + prisma.appSettings.findFirst.mockResolvedValue(null); + }); + + it("getUser returns same result", async () => { + const oldResult = await getUser("test@example.com"); + + const testLayer = UserServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getUser("test@example.com")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getUser with undefined email returns null for both", async () => { + const oldResult = await getUser(undefined); + + const testLayer = UserServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(undefined))).pipe( + Effect.provide(testLayer) + ) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toBeNull(); + }); + + it("getTeamsWithPerms returns same result", async () => { + const oldResult = await getTeamsWithPerms("test@example.com"); + + const testLayer = UserServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getTeamsWithPerms("test@example.com")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); +}); + +// --------------------------------------------------------------------------- +// Tournament domain +// --------------------------------------------------------------------------- +import { + getTournament, + getUserTournaments, +} from "@/data/tournament-dto"; +import { + TournamentService, + TournamentServiceLive, +} from "@/data/tournament/tournament-service"; + +describe("Tournament service parity", () => { + const mockTournament = { + id: 1, + name: "Test Tournament", + creatorId: "user-1", + format: "SINGLE_ELIMINATION" as const, + status: "IN_PROGRESS" as const, + teamSize: 8, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + teams: [], + rounds: [], + matches: [], + }; + + beforeEach(() => { + prisma.tournament.findUnique.mockResolvedValue(mockTournament); + prisma.tournament.findMany.mockResolvedValue([]); + }); + + it("getTournament returns same result", async () => { + const oldResult = await getTournament(1); + + const testLayer = TournamentServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getTournament(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getUserTournaments returns same result", async () => { + const oldResult = await getUserTournaments("user-1"); + + const testLayer = TournamentServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getUserTournaments("user-1")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); +}); + +// --------------------------------------------------------------------------- +// Hero domain +// --------------------------------------------------------------------------- +import { getAllKillsForHero, getAllDeathsForHero } from "@/data/hero-dto"; +import { HeroService, HeroServiceLive } from "@/data/hero/service"; + +describe("Hero service parity", () => { + const mockKills = [ + { + id: 1, + scrimId: 1, + event_type: "kill" as const, + match_time: 100, + MapDataId: 10, + attacker_team: "Team A", + attacker_name: "Player1", + attacker_hero: "Ana", + victim_team: "Team B", + victim_name: "Player2", + victim_hero: "Genji", + event_ability: "Biotic Rifle", + event_damage: 70, + is_critical_hit: false, + is_environmental: false, + round_number: 1, + objective_index: 0, + attacker_x: 0, + attacker_y: 0, + attacker_z: 0, + victim_x: 0, + victim_y: 0, + victim_z: 0, + }, + ]; + + beforeEach(() => { + prisma.scrim.findMany.mockResolvedValue([ + { + id: 1, + maps: [{ id: 1, mapData: [{ id: 10 }] }], + } as never, + ]); + prisma.kill.findMany.mockResolvedValue(mockKills as never); + }); + + it("getAllKillsForHero returns same result", async () => { + const oldResult = await getAllKillsForHero([1], "Ana"); + + const testLayer = HeroServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + HeroService.pipe( + Effect.flatMap((svc) => svc.getAllKillsForHero([1], "Ana")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getAllDeathsForHero returns same result", async () => { + const oldResult = await getAllDeathsForHero([1], "Ana"); + + const testLayer = HeroServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + HeroService.pipe( + Effect.flatMap((svc) => svc.getAllDeathsForHero([1], "Ana")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); +}); + +// --------------------------------------------------------------------------- +// Admin domain +// --------------------------------------------------------------------------- +import { getUnlabeledMatches } from "@/data/data-labeling-dto"; +import { + DataLabelingService, + DataLabelingServiceLive, +} from "@/data/admin/data-labeling-service"; + +describe("Admin service parity", () => { + beforeEach(() => { + prisma.scoutingMatch.findMany.mockResolvedValue([]); + prisma.scoutingMatch.count.mockResolvedValue(0); + }); + + it("getUnlabeledMatches returns same result for empty data", async () => { + const oldResult = await getUnlabeledMatches(0, 10); + + const testLayer = DataLabelingServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + DataLabelingService.pipe( + Effect.flatMap((svc) => svc.getUnlabeledMatches(0, 10)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); +}); + +// --------------------------------------------------------------------------- +// Scrim domain (basic) +// --------------------------------------------------------------------------- +import { getScrim } from "@/data/scrim-dto"; +import { ScrimService, ScrimServiceLive } from "@/data/scrim/scrim-service"; + +describe("Scrim service parity", () => { + const mockScrim = { + id: 1, + name: "Test Scrim", + date: new Date("2024-06-01"), + creatorId: "user-1", + teamId: 1, + guestMode: false, + }; + + beforeEach(() => { + prisma.scrim.findFirst.mockResolvedValue(mockScrim as never); + }); + + it("getScrim returns same result", async () => { + const oldResult = await getScrim(1); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(1))).pipe( + Effect.provide(testLayer) + ) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getScrim with non-existent id returns null for both", async () => { + prisma.scrim.findFirst.mockResolvedValue(null); + + const oldResult = await getScrim(999); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(999))).pipe( + Effect.provide(testLayer) + ) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Team shared-core (pure functions — no mocking needed) +// --------------------------------------------------------------------------- +import { + buildFinalRoundMap, + buildMatchStartMap, + findTeamNameForMapInMemory, +} from "@/data/team-shared-core"; +import { + buildFinalRoundMap as newBuildFinalRoundMap, + buildMatchStartMap as newBuildMatchStartMap, + findTeamNameForMapInMemory as newFindTeamNameForMapInMemory, +} from "@/data/team/shared-core"; + +describe("Team shared-core pure function parity", () => { + it("findTeamNameForMapInMemory returns same result", () => { + const stats = [ + { player_name: "Player1", player_team: "Team A", MapDataId: 1 }, + { player_name: "Player2", player_team: "Team A", MapDataId: 1 }, + { player_name: "Player3", player_team: "Team B", MapDataId: 1 }, + ]; + const roster = new Set(["Player1", "Player2"]); + + const oldResult = findTeamNameForMapInMemory(1, stats, roster); + const newResult = newFindTeamNameForMapInMemory(1, stats, roster); + + expect(newResult).toEqual(oldResult); + expect(newResult).toBe("Team A"); + }); + + it("buildMatchStartMap returns same result", () => { + const matchStarts = [ + { + id: 1, + MapDataId: 10, + map_name: "Lijiang Tower", + map_type: "Control" as const, + team_1_name: "A", + team_2_name: "B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + { + id: 2, + MapDataId: 20, + map_name: "Numbani", + map_type: "Hybrid" as const, + team_1_name: "A", + team_2_name: "B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + ] as Parameters[0]; + + const oldResult = buildMatchStartMap(matchStarts); + const newResult = newBuildMatchStartMap(matchStarts); + + expect(newResult).toEqual(oldResult); + expect(newResult.size).toBe(2); + expect(newResult.get(10)?.map_name).toBe("Lijiang Tower"); + }); + + it("buildFinalRoundMap keeps highest round number per map", () => { + const rounds = [ + { + id: 1, + MapDataId: 10, + round_number: 1, + match_time: 100, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 1, + team_2_score: 0, + objective_index: 0, + capturing_team: null, + }, + { + id: 2, + MapDataId: 10, + round_number: 3, + match_time: 300, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 2, + team_2_score: 1, + objective_index: 0, + capturing_team: null, + }, + { + id: 3, + MapDataId: 10, + round_number: 2, + match_time: 200, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 1, + team_2_score: 1, + objective_index: 0, + capturing_team: null, + }, + ] as Parameters[0]; + + const oldResult = buildFinalRoundMap(rounds); + const newResult = newBuildFinalRoundMap(rounds); + + expect(newResult).toEqual(oldResult); + expect(newResult.get(10)?.round_number).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// Comparison computation (pure functions) +// --------------------------------------------------------------------------- +import { + aggregatePlayerStats as oldAggregate, + calculateTrends as oldCalculateTrends, +} from "@/data/comparison-dto"; +import { + aggregatePlayerStats as newAggregate, + calculateTrends as newCalculateTrends, +} from "@/data/comparison/computation"; + +describe("Comparison computation parity", () => { + it("aggregatePlayerStats returns same result for empty input", () => { + const oldResult = oldAggregate([], []); + const newResult = newAggregate([], []); + expect(newResult).toEqual(oldResult); + }); + + it("calculateTrends returns same result for empty input", () => { + const oldResult = oldCalculateTrends([], []); + const newResult = newCalculateTrends([], []); + expect(newResult).toEqual(oldResult); + }); +}); + +// --------------------------------------------------------------------------- +// Scouting opponent strength (division-by-zero fix verification) +// --------------------------------------------------------------------------- +import { + OpponentStrengthService, + OpponentStrengthServiceLive, +} from "@/data/scouting/opponent-strength-service"; + +describe("Opponent strength edge cases", () => { + it("getTeamStrengthPercentile handles single-team case without NaN", async () => { + prisma.scoutingMatch.findMany.mockResolvedValue([ + { + id: 1, + team1: "TST", + team1FullName: "Test Team", + team2: "OPP", + team2FullName: "Opponent", + team1Score: 3, + team2Score: 0, + matchDate: new Date("2024-06-01"), + tournamentId: 1, + vods: [], + } as never, + ]); + + const testLayer = OpponentStrengthServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const result = await Effect.runPromise( + OpponentStrengthService.pipe( + Effect.flatMap((svc) => svc.getTeamStrengthPercentile("TST")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(result).not.toBeNaN(); + if (result !== null) { + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(100); + } + }); +}); + +// --------------------------------------------------------------------------- +// Team shared data service parity +// --------------------------------------------------------------------------- +import { + getTeamRoster, + getBaseTeamData, + getExtendedTeamData, +} from "@/data/team-shared-data"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "@/data/team/shared-data-service"; + +describe("Team shared data service parity", () => { + const mockMapRecords = [ + { + id: 1, + name: "Lijiang Tower", + mapData: [{ id: 10 }, { id: 11 }], + }, + { + id: 2, + name: "Numbani", + mapData: [{ id: 20 }], + }, + ]; + + const mockPlayerStats = [ + { + player_name: "Alice", + player_team: "Team A", + MapDataId: 10, + player_hero: "Ana", + hero_time_played: 600, + eliminations: 5, + final_blows: 3, + deaths: 2, + offensive_assists: 10, + hero_damage_dealt: 3000, + damage_taken: 1500, + healing_dealt: 8000, + ultimates_earned: 3, + ultimates_used: 2, + }, + { + player_name: "Bob", + player_team: "Team A", + MapDataId: 10, + player_hero: "Reinhardt", + hero_time_played: 600, + eliminations: 8, + final_blows: 5, + deaths: 3, + offensive_assists: 0, + hero_damage_dealt: 5000, + damage_taken: 8000, + healing_dealt: 0, + ultimates_earned: 2, + ultimates_used: 2, + }, + { + player_name: "Charlie", + player_team: "Team B", + MapDataId: 10, + player_hero: "Genji", + hero_time_played: 600, + eliminations: 10, + final_blows: 7, + deaths: 4, + offensive_assists: 0, + hero_damage_dealt: 7000, + damage_taken: 3000, + healing_dealt: 0, + ultimates_earned: 4, + ultimates_used: 3, + }, + { + player_name: "Alice", + player_team: "Team A", + MapDataId: 11, + player_hero: "Mercy", + hero_time_played: 600, + eliminations: 1, + final_blows: 0, + deaths: 2, + offensive_assists: 15, + hero_damage_dealt: 500, + damage_taken: 1000, + healing_dealt: 10000, + ultimates_earned: 5, + ultimates_used: 4, + }, + { + player_name: "Bob", + player_team: "Team A", + MapDataId: 11, + player_hero: "Winston", + hero_time_played: 600, + eliminations: 6, + final_blows: 4, + deaths: 5, + offensive_assists: 0, + hero_damage_dealt: 4000, + damage_taken: 7000, + healing_dealt: 0, + ultimates_earned: 3, + ultimates_used: 2, + }, + { + player_name: "Alice", + player_team: "Team A", + MapDataId: 20, + player_hero: "Ana", + hero_time_played: 400, + eliminations: 3, + final_blows: 2, + deaths: 1, + offensive_assists: 8, + hero_damage_dealt: 2000, + damage_taken: 1000, + healing_dealt: 6000, + ultimates_earned: 2, + ultimates_used: 1, + }, + { + player_name: "Bob", + player_team: "Team A", + MapDataId: 20, + player_hero: "Reinhardt", + hero_time_played: 400, + eliminations: 5, + final_blows: 3, + deaths: 2, + offensive_assists: 0, + hero_damage_dealt: 3000, + damage_taken: 5000, + healing_dealt: 0, + ultimates_earned: 1, + ultimates_used: 1, + }, + ]; + + const mockMatchStarts = [ + { + id: 1, + MapDataId: 10, + map_name: "Lijiang Tower", + map_type: "Control" as const, + team_1_name: "Team A", + team_2_name: "Team B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + { + id: 2, + MapDataId: 11, + map_name: "Lijiang Tower", + map_type: "Control" as const, + team_1_name: "Team A", + team_2_name: "Team B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + { + id: 3, + MapDataId: 20, + map_name: "Numbani", + map_type: "Hybrid" as const, + team_1_name: "Team A", + team_2_name: "Team B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + ]; + + const mockFinalRounds = [ + { + id: 1, + MapDataId: 10, + round_number: 3, + match_time: 300, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 2, + team_2_score: 1, + objective_index: 0, + capturing_team: null, + }, + { + id: 2, + MapDataId: 11, + round_number: 2, + match_time: 200, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 2, + team_2_score: 0, + objective_index: 0, + capturing_team: null, + }, + { + id: 3, + MapDataId: 20, + round_number: 4, + match_time: 400, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 3, + team_2_score: 2, + objective_index: 0, + capturing_team: null, + }, + ]; + + it("getTeamRoster returns same roster from player stats across maps", async () => { + // First call for roster itself, second call for base data + prisma.map.findMany.mockResolvedValue(mockMapRecords as never); + prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); + + const oldResult = await getTeamRoster(1); + + const testLayer = TeamSharedDataServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult.sort()).toEqual(oldResult.sort()); + }); + + it("getTeamRoster returns empty array when no maps exist", async () => { + prisma.map.findMany.mockResolvedValue([] as never); + + const oldResult = await getTeamRoster(999); + + const testLayer = TeamSharedDataServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(999)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toEqual([]); + }); + + it("getBaseTeamData returns same structure with mock data", async () => { + prisma.map.findMany.mockResolvedValue(mockMapRecords as never); + prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); + prisma.matchStart.findMany.mockResolvedValue(mockMatchStarts as never); + prisma.roundEnd.findMany.mockResolvedValue(mockFinalRounds as never); + prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); + prisma.payloadProgress.findMany.mockResolvedValue([] as never); + prisma.pointProgress.findMany.mockResolvedValue([] as never); + + const oldResult = await getBaseTeamData(1); + + const testLayer = TeamSharedDataServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getBaseTeamData(1)) + ).pipe(Effect.provide(testLayer)) + ); + + // Compare scalar fields (Set is not structurally comparable) + expect(newResult.teamId).toEqual(oldResult.teamId); + expect(newResult.teamRoster.sort()).toEqual(oldResult.teamRoster.sort()); + expect(newResult.mapDataIds.sort()).toEqual(oldResult.mapDataIds.sort()); + expect(newResult.mapDataRecords).toEqual(oldResult.mapDataRecords); + expect(newResult.allPlayerStats).toEqual(oldResult.allPlayerStats); + expect(newResult.matchStarts).toEqual(oldResult.matchStarts); + expect(newResult.finalRounds).toEqual(oldResult.finalRounds); + expect(newResult.captures).toEqual(oldResult.captures); + expect(newResult.payloadProgresses).toEqual(oldResult.payloadProgresses); + expect(newResult.pointProgresses).toEqual(oldResult.pointProgresses); + }); + + it("getBaseTeamData returns empty structure when no maps", async () => { + prisma.map.findMany.mockResolvedValue([] as never); + prisma.playerStat.findMany.mockResolvedValue([] as never); + + const oldResult = await getBaseTeamData(999); + + const testLayer = TeamSharedDataServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getBaseTeamData(999)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult.teamId).toEqual(oldResult.teamId); + expect(newResult.teamRoster).toEqual(oldResult.teamRoster); + expect(newResult.mapDataRecords).toEqual(oldResult.mapDataRecords); + expect(newResult.mapDataIds).toEqual(oldResult.mapDataIds); + expect(newResult.allPlayerStats).toEqual(oldResult.allPlayerStats); + expect(newResult.matchStarts).toEqual(oldResult.matchStarts); + }); + + it("getExtendedTeamData returns same result with kills/rezzes/ults", async () => { + const mockKills = [ + { + id: 1, + scrimId: 1, + event_type: "kill" as const, + match_time: 50, + MapDataId: 10, + attacker_team: "Team A", + attacker_name: "Alice", + attacker_hero: "Ana", + victim_team: "Team B", + victim_name: "Charlie", + victim_hero: "Genji", + event_ability: "Biotic Rifle", + event_damage: 70, + is_critical_hit: false, + is_environmental: false, + round_number: 1, + objective_index: 0, + attacker_x: 0, + attacker_y: 0, + attacker_z: 0, + victim_x: 0, + victim_y: 0, + victim_z: 0, + }, + ]; + const mockRezzes = [ + { + id: 1, + scrimId: 1, + event_type: "mercy_rez" as const, + match_time: 60, + MapDataId: 10, + resurrecter_team: "Team A", + resurrecter_player: "Alice", + resurrecter_hero: "Mercy", + resurrectee_team: "Team A", + resurrectee_player: "Bob", + resurrectee_hero: "Reinhardt", + round_number: 1, + objective_index: 0, + }, + ]; + const mockUltimates = [ + { + id: 1, + scrimId: 1, + event_type: "ultimate_start" as const, + match_time: 70, + MapDataId: 10, + player_team: "Team A", + player_name: "Alice", + player_hero: "Ana", + ultimate_id: 1, + round_number: 1, + objective_index: 0, + }, + ]; + + prisma.map.findMany.mockResolvedValue(mockMapRecords as never); + prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); + prisma.matchStart.findMany.mockResolvedValue(mockMatchStarts as never); + prisma.roundEnd.findMany.mockResolvedValue(mockFinalRounds as never); + prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); + prisma.payloadProgress.findMany.mockResolvedValue([] as never); + prisma.pointProgress.findMany.mockResolvedValue([] as never); + prisma.kill.findMany.mockResolvedValue(mockKills as never); + prisma.mercyRez.findMany.mockResolvedValue(mockRezzes as never); + prisma.ultimateStart.findMany.mockResolvedValue(mockUltimates as never); + + const oldResult = await getExtendedTeamData(1); + + const testLayer = TeamSharedDataServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getExtendedTeamData(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult.teamId).toEqual(oldResult.teamId); + expect(newResult.teamRoster.sort()).toEqual(oldResult.teamRoster.sort()); + expect(newResult.allKills).toEqual(oldResult.allKills); + expect(newResult.allRezzes).toEqual(oldResult.allRezzes); + expect(newResult.allUltimates).toEqual(oldResult.allUltimates); + }); +}); + +// --------------------------------------------------------------------------- +// Team stats service parity +// --------------------------------------------------------------------------- +import { + getTeamWinrates, + getTopMapsByPlaytime, + getTeamNameForRoster, +} from "@/data/team-stats-dto"; +import { + TeamStatsService, + TeamStatsServiceLive, +} from "@/data/team/stats-service"; + +describe("Team stats service parity", () => { + it("getTeamWinrates returns zeroed structure with empty data", async () => { + prisma.map.findMany.mockResolvedValue([] as never); + prisma.playerStat.findMany.mockResolvedValue([] as never); + + const oldResult = await getTeamWinrates(999); + + const testLayer = TeamStatsServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(999)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult.overallWins).toBe(0); + expect(newResult.overallLosses).toBe(0); + expect(newResult.overallWinrate).toBe(0); + expect(newResult.byMap).toEqual({}); + }); + + it("getTeamWinrates computes same winrates with Control map data", async () => { + const mapRecords = [ + { id: 1, name: "Lijiang Tower", mapData: [{ id: 10 }] }, + ]; + const playerStats = [ + { + player_name: "Alice", + player_team: "Team A", + MapDataId: 10, + player_hero: "Ana", + hero_time_played: 600, + eliminations: 5, + final_blows: 3, + deaths: 2, + offensive_assists: 10, + hero_damage_dealt: 3000, + damage_taken: 1500, + healing_dealt: 8000, + ultimates_earned: 3, + ultimates_used: 2, + }, + { + player_name: "Bob", + player_team: "Team A", + MapDataId: 10, + player_hero: "Reinhardt", + hero_time_played: 600, + eliminations: 8, + final_blows: 5, + deaths: 3, + offensive_assists: 0, + hero_damage_dealt: 5000, + damage_taken: 8000, + healing_dealt: 0, + ultimates_earned: 2, + ultimates_used: 2, + }, + { + player_name: "Eve", + player_team: "Team B", + MapDataId: 10, + player_hero: "Genji", + hero_time_played: 600, + eliminations: 10, + final_blows: 7, + deaths: 4, + offensive_assists: 0, + hero_damage_dealt: 7000, + damage_taken: 3000, + healing_dealt: 0, + ultimates_earned: 4, + ultimates_used: 3, + }, + ]; + const matchStarts = [ + { + id: 1, + MapDataId: 10, + map_name: "Lijiang Tower", + map_type: "Control" as const, + team_1_name: "Team A", + team_2_name: "Team B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + ]; + const finalRounds = [ + { + id: 1, + MapDataId: 10, + round_number: 3, + match_time: 300, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 2, + team_2_score: 1, + objective_index: 0, + capturing_team: null, + }, + ]; + + prisma.map.findMany.mockResolvedValue(mapRecords as never); + prisma.playerStat.findMany.mockResolvedValue(playerStats as never); + prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); + prisma.roundEnd.findMany.mockResolvedValue(finalRounds as never); + prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); + prisma.payloadProgress.findMany.mockResolvedValue([] as never); + prisma.pointProgress.findMany.mockResolvedValue([] as never); + + const oldResult = await getTeamWinrates(1); + + const testLayer = TeamStatsServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getTopMapsByPlaytime returns same sorted results", async () => { + const mapRecords = [ + { id: 1, name: "Lijiang Tower", mapData: [{ id: 10 }] }, + { id: 2, name: "Numbani", mapData: [{ id: 20 }] }, + ]; + const matchEnds = [ + { match_time: 600, MapDataId: 10 }, + { match_time: 400, MapDataId: 20 }, + ]; + + prisma.map.findMany.mockResolvedValue(mapRecords as never); + prisma.matchEnd.findMany.mockResolvedValue(matchEnds as never); + + const oldResult = await getTopMapsByPlaytime(1); + + const testLayer = TeamStatsServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTopMapsByPlaytime(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult[0].name).toBe("Lijiang Tower"); + expect(newResult[0].playtime).toBe(600); + }); + + it("getTeamNameForRoster returns same team name", async () => { + // Roster setup: Alice and Bob are the team + const mapRecords = [{ mapData: [{ id: 10 }] }]; + const rosterPlayerStats = [ + { player_name: "Alice", player_team: "Team A", MapDataId: 10 }, + { player_name: "Bob", player_team: "Team A", MapDataId: 10 }, + { player_name: "Eve", player_team: "Team B", MapDataId: 10 }, + ]; + const distinctStats = [ + { player_name: "Alice", player_team: "Team A" }, + { player_name: "Bob", player_team: "Team A" }, + { player_name: "Eve", player_team: "Team B" }, + ]; + + // Mock for getTeamRoster (called by both old and new) + prisma.map.findMany.mockResolvedValue(mapRecords as never); + prisma.playerStat.findMany + .mockResolvedValueOnce(rosterPlayerStats as never) // old: getTeamRoster + .mockResolvedValueOnce(distinctStats as never) // old: getTeamNameForRoster + .mockResolvedValueOnce(rosterPlayerStats as never) // new: getTeamRoster + .mockResolvedValueOnce(distinctStats as never); // new: getTeamNameForRoster + + const oldResult = await getTeamNameForRoster(1, 10); + + const testLayer = TeamStatsServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamNameForRoster(1, 10)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toBe("Team A"); + }); +}); + +// --------------------------------------------------------------------------- +// Scrim service (expanded) — kills, deaths, stats, winrates +// --------------------------------------------------------------------------- +import { + getAllKillsForPlayer, + getAllDeathsForPlayer, + getAllStatsForPlayer, + getAllMapWinratesForPlayer, +} from "@/data/scrim-dto"; + +describe("Scrim service expanded parity", () => { + const mockScrimsMaps = [ + { + id: 1, + maps: [{ mapData: [{ id: 10 }] }], + date: new Date("2024-06-01"), + }, + ]; + + const mockKills = [ + { + id: 1, + scrimId: 1, + event_type: "kill" as const, + match_time: 100, + MapDataId: 10, + attacker_team: "Team A", + attacker_name: "Alice", + attacker_hero: "Ana", + victim_team: "Team B", + victim_name: "Bob", + victim_hero: "Genji", + event_ability: "Biotic Rifle", + event_damage: 70, + is_critical_hit: false, + is_environmental: false, + round_number: 1, + objective_index: 0, + attacker_x: 0, + attacker_y: 0, + attacker_z: 0, + victim_x: 0, + victim_y: 0, + victim_z: 0, + }, + { + id: 2, + scrimId: 1, + event_type: "kill" as const, + match_time: 150, + MapDataId: 10, + attacker_team: "Team B", + attacker_name: "Bob", + attacker_hero: "Genji", + victim_team: "Team A", + victim_name: "Alice", + victim_hero: "Ana", + event_ability: "Dragonblade", + event_damage: 120, + is_critical_hit: false, + is_environmental: false, + round_number: 1, + objective_index: 0, + attacker_x: 0, + attacker_y: 0, + attacker_z: 0, + victim_x: 0, + victim_y: 0, + victim_z: 0, + }, + ]; + + it("getAllKillsForPlayer returns same kills", async () => { + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + // Only Alice's kills (attacker_name = Alice) + prisma.kill.findMany.mockResolvedValue([mockKills[0]] as never); + + const oldResult = await getAllKillsForPlayer([1], "Alice"); + + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + prisma.kill.findMany.mockResolvedValue([mockKills[0]] as never); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getAllKillsForPlayer([1], "Alice")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getAllDeathsForPlayer returns same deaths", async () => { + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + // Alice's deaths (victim_name = Alice) + prisma.kill.findMany.mockResolvedValue([mockKills[1]] as never); + + const oldResult = await getAllDeathsForPlayer([1], "Alice"); + + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + prisma.kill.findMany.mockResolvedValue([mockKills[1]] as never); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getAllDeathsForPlayer([1], "Alice")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getAllStatsForPlayer returns same results from raw SQL mock", async () => { + const mockRawStats = [ + { + id: 100, + scrimId: 1, + event_type: "player_stat", + match_time: 600, + MapDataId: 10, + player_name: "Alice", + player_team: "Team A", + player_hero: "Ana", + hero_time_played: 600, + eliminations: 5, + final_blows: 3, + deaths: 2, + all_damage_dealt: 4000, + barrier_damage_dealt: 500, + hero_damage_dealt: 3000, + healing_dealt: 8000, + healing_received: 1000, + self_healing: 200, + damage_taken: 1500, + damage_blocked: 0, + defensive_assists: 5, + offensive_assists: 10, + ultimates_earned: 3, + ultimates_used: 2, + multikill_best: 0, + multikills: 0, + solo_kills: 1, + objective_kills: 2, + environmental_kills: 0, + environmental_deaths: 0, + critical_hits: 10, + critical_hit_accuracy: 25, + scoped_accuracy: 50, + scoped_critical_hit_accuracy: 15, + scoped_critical_hit_kills: 1, + shots_fired: 100, + shots_hit: 50, + shots_missed: 50, + scoped_shots_fired: 60, + scoped_shots_hit: 30, + weapon_accuracy: 50, + round_number: 3, + objective_index: 0, + }, + ]; + + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + (prisma.$queryRaw as unknown as ReturnType).mockResolvedValue( + mockRawStats + ); + + const oldResult = await getAllStatsForPlayer([1], "Alice"); + + prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); + (prisma.$queryRaw as unknown as ReturnType).mockResolvedValue( + mockRawStats + ); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getAllStatsForPlayer([1], "Alice")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getAllMapWinratesForPlayer returns same winrates for Control map", async () => { + const scrimsWithDate = [ + { + id: 1, + maps: [{ mapData: [{ id: 10 }] }], + date: new Date("2024-06-01"), + }, + ]; + const matchStarts = [ + { + id: 1, + MapDataId: 10, + map_name: "Lijiang Tower", + map_type: "Control" as const, + team_1_name: "Team A", + team_2_name: "Team B", + match_time: 0, + scrimId: 1, + event_type: "match_start" as const, + round_number: 0, + objective_index: 0, + }, + ]; + const roundEnds = [ + { + id: 1, + MapDataId: 10, + round_number: 3, + match_time: 300, + scrimId: 1, + event_type: "round_end" as const, + team_1_score: 2, + team_2_score: 1, + objective_index: 0, + capturing_team: null, + }, + ]; + const playerStatsForWinrate = [ + { + id: 100, + scrimId: 1, + event_type: "player_stat", + match_time: 300, + MapDataId: 10, + player_name: "Alice", + player_team: "Team A", + player_hero: "Ana", + hero_time_played: 300, + eliminations: 5, + final_blows: 3, + deaths: 2, + all_damage_dealt: 4000, + barrier_damage_dealt: 500, + hero_damage_dealt: 3000, + healing_dealt: 8000, + healing_received: 1000, + self_healing: 200, + damage_taken: 1500, + damage_blocked: 0, + defensive_assists: 5, + offensive_assists: 10, + ultimates_earned: 3, + ultimates_used: 2, + multikill_best: 0, + multikills: 0, + solo_kills: 1, + objective_kills: 2, + environmental_kills: 0, + environmental_deaths: 0, + critical_hits: 10, + critical_hit_accuracy: 25, + scoped_accuracy: 50, + scoped_critical_hit_accuracy: 15, + scoped_critical_hit_kills: 1, + shots_fired: 100, + shots_hit: 50, + shots_missed: 50, + scoped_shots_fired: 60, + scoped_shots_hit: 30, + weapon_accuracy: 50, + round_number: 3, + objective_index: 0, + }, + ]; + + // Old DTO calls + prisma.scrim.findMany.mockResolvedValue(scrimsWithDate as never); + prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); + prisma.roundEnd.findMany.mockResolvedValue(roundEnds as never); + prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); + prisma.payloadProgress.findMany.mockResolvedValue([] as never); + prisma.pointProgress.findMany.mockResolvedValue([] as never); + prisma.playerStat.findMany.mockResolvedValue( + playerStatsForWinrate as never + ); + + const oldResult = await getAllMapWinratesForPlayer([1], "Alice"); + + // New Effect service calls (same mocks) + prisma.scrim.findMany.mockResolvedValue(scrimsWithDate as never); + prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); + prisma.roundEnd.findMany.mockResolvedValue(roundEnds as never); + prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); + prisma.payloadProgress.findMany.mockResolvedValue([] as never); + prisma.pointProgress.findMany.mockResolvedValue([] as never); + prisma.playerStat.findMany.mockResolvedValue( + playerStatsForWinrate as never + ); + + const testLayer = ScrimServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer([1], "Alice")) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult.length).toBe(1); + expect(newResult[0].map).toBe("Lijiang Tower"); + // Team A won (score 2-1) and Alice is on Team A + expect(newResult[0].wins).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Player service parity +// --------------------------------------------------------------------------- +import { getMostPlayedHeroes } from "@/data/player-dto"; +import { PlayerService, PlayerServiceLive } from "@/data/player/player-service"; + +vi.mock("@/lib/map-data-resolver", () => ({ + resolveMapDataId: vi.fn(async (id: number) => id), +})); + +describe("Player service parity", () => { + const mockHeroStats = [ + { + player_name: "Alice", + player_team: "Team A", + player_hero: "Ana", + hero_time_played: 600, + }, + { + player_name: "Bob", + player_team: "Team A", + player_hero: "Reinhardt", + hero_time_played: 500, + }, + { + player_name: "Charlie", + player_team: "Team B", + player_hero: "Genji", + hero_time_played: 400, + }, + ]; + + it("getMostPlayedHeroes returns same results", async () => { + prisma.mapData.findFirst.mockResolvedValue({ id: 10 } as never); + prisma.playerStat.findMany.mockResolvedValue(mockHeroStats as never); + + const oldResult = await getMostPlayedHeroes(10); + + prisma.mapData.findFirst.mockResolvedValue({ id: 10 } as never); + prisma.playerStat.findMany.mockResolvedValue(mockHeroStats as never); + + const testLayer = PlayerServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + PlayerService.pipe( + Effect.flatMap((svc) => svc.getMostPlayedHeroes(10)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getMostPlayedHeroes returns empty array for empty data", async () => { + prisma.mapData.findFirst.mockResolvedValue({ id: 99 } as never); + prisma.playerStat.findMany.mockResolvedValue([] as never); + + const oldResult = await getMostPlayedHeroes(99); + + prisma.mapData.findFirst.mockResolvedValue({ id: 99 } as never); + prisma.playerStat.findMany.mockResolvedValue([] as never); + + const testLayer = PlayerServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + PlayerService.pipe( + Effect.flatMap((svc) => svc.getMostPlayedHeroes(99)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Map group service parity +// --------------------------------------------------------------------------- +import { getMapGroupsForTeam, getMapGroupById } from "@/data/map-group-dto"; +import { MapGroupService, MapGroupServiceLive } from "@/data/map/group-service"; + +describe("Map group service parity", () => { + const mockMapGroups = [ + { + id: 1, + name: "Control Maps", + description: "All control maps", + teamId: 1, + mapIds: [1, 2, 3], + category: "mode", + createdBy: "user-1", + createdAt: new Date("2024-06-01"), + updatedAt: new Date("2024-06-01"), + }, + { + id: 2, + name: "Escort Maps", + description: "All escort maps", + teamId: 1, + mapIds: [4, 5], + category: "mode", + createdBy: "user-1", + createdAt: new Date("2024-05-01"), + updatedAt: new Date("2024-05-01"), + }, + ]; + + it("getMapGroupsForTeam returns same results", async () => { + prisma.mapGroup.findMany.mockResolvedValue(mockMapGroups as never); + + const oldResult = await getMapGroupsForTeam(1); + + prisma.mapGroup.findMany.mockResolvedValue(mockMapGroups as never); + + const testLayer = MapGroupServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => svc.getMapGroupsForTeam(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getMapGroupsForTeam returns empty array for team with no groups", async () => { + prisma.mapGroup.findMany.mockResolvedValue([] as never); + + const oldResult = await getMapGroupsForTeam(999); + + prisma.mapGroup.findMany.mockResolvedValue([] as never); + + const testLayer = MapGroupServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => svc.getMapGroupsForTeam(999)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toEqual([]); + }); + + it("getMapGroupById returns same result", async () => { + prisma.mapGroup.findUnique.mockResolvedValue(mockMapGroups[0] as never); + + const oldResult = await getMapGroupById(1); + + prisma.mapGroup.findUnique.mockResolvedValue(mockMapGroups[0] as never); + + const testLayer = MapGroupServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => svc.getMapGroupById(1)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + }); + + it("getMapGroupById returns null for non-existent group", async () => { + prisma.mapGroup.findUnique.mockResolvedValue(null); + + const oldResult = await getMapGroupById(999); + + prisma.mapGroup.findUnique.mockResolvedValue(null); + + const testLayer = MapGroupServiceLive.pipe( + Layer.provide( + Layer.merge( + Logger.replace(Logger.defaultLogger, TestLogger), + Layer.empty + ) + ) + ); + + const newResult = await Effect.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => svc.getMapGroupById(999)) + ).pipe(Effect.provide(testLayer)) + ); + + expect(newResult).toEqual(oldResult); + expect(newResult).toBeNull(); + }); +}); diff --git a/src/data/admin/index.ts b/src/data/admin/index.ts index 51d49ba47..7c134f4b1 100644 --- a/src/data/admin/index.ts +++ b/src/data/admin/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { DataLabelingService, DataLabelingServiceLive, diff --git a/src/data/comparison/index.ts b/src/data/comparison/index.ts index af6b88f22..ea296d7a8 100644 --- a/src/data/comparison/index.ts +++ b/src/data/comparison/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { ComparisonAggregationService, ComparisonAggregationServiceLive, diff --git a/src/data/hero/index.ts b/src/data/hero/index.ts index 01ab94c16..fa2bd3c6d 100644 --- a/src/data/hero/index.ts +++ b/src/data/hero/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { HeroService, HeroServiceLive } from "./service"; export type { HeroServiceInterface } from "./service"; diff --git a/src/data/intelligence/index.ts b/src/data/intelligence/index.ts index f1151c72b..de34dc3ea 100644 --- a/src/data/intelligence/index.ts +++ b/src/data/intelligence/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { HeroBanIntelligenceService, HeroBanIntelligenceServiceLive, diff --git a/src/data/map/heatmap/index.ts b/src/data/map/heatmap/index.ts index 40441ec4a..5f987ecb2 100644 --- a/src/data/map/heatmap/index.ts +++ b/src/data/map/heatmap/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { HeatmapService, HeatmapServiceLive } from "./service"; export type { HeatmapServiceInterface } from "./service"; diff --git a/src/data/map/index.ts b/src/data/map/index.ts index 2c143564a..ea67295db 100644 --- a/src/data/map/index.ts +++ b/src/data/map/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { HeatmapService, HeatmapServiceLive } from "./heatmap"; export type { HeatmapServiceInterface } from "./heatmap"; export type { diff --git a/src/data/map/killfeed/index.ts b/src/data/map/killfeed/index.ts index bac041692..b63789323 100644 --- a/src/data/map/killfeed/index.ts +++ b/src/data/map/killfeed/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { KillfeedService, KillfeedServiceLive, diff --git a/src/data/map/replay/index.ts b/src/data/map/replay/index.ts index b8a6f8cd4..79c972e35 100644 --- a/src/data/map/replay/index.ts +++ b/src/data/map/replay/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { ReplayService, ReplayServiceLive } from "./service"; export type { ReplayServiceInterface } from "./service"; diff --git a/src/data/player/index.ts b/src/data/player/index.ts index 172fcbfb5..3dfccfd42 100644 --- a/src/data/player/index.ts +++ b/src/data/player/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { PlayerService, PlayerServiceLive } from "./player-service"; export type { PlayerServiceInterface } from "./player-service"; diff --git a/src/data/runtime.ts b/src/data/runtime.ts index 3ffcc5c5b..bfdd3c516 100644 --- a/src/data/runtime.ts +++ b/src/data/runtime.ts @@ -1,3 +1,5 @@ +import "server-only"; + import { ManagedRuntime } from "effect"; import { DataLayerLive } from "./layer"; diff --git a/src/data/scouting/index.ts b/src/data/scouting/index.ts index 2b3d6b908..6deba1de3 100644 --- a/src/data/scouting/index.ts +++ b/src/data/scouting/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { ScoutingService, ScoutingServiceLive } from "./scouting-service"; export type { ScoutingServiceInterface, diff --git a/src/data/scrim/index.ts b/src/data/scrim/index.ts index 121ff6a83..8274943ef 100644 --- a/src/data/scrim/index.ts +++ b/src/data/scrim/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { ScrimService, ScrimServiceLive } from "./scrim-service"; export type { ScrimServiceInterface, Winrate } from "./scrim-service"; diff --git a/src/data/scrim/overview-service.ts b/src/data/scrim/overview-service.ts index 4bdcba6a7..208d53a50 100644 --- a/src/data/scrim/overview-service.ts +++ b/src/data/scrim/overview-service.ts @@ -7,12 +7,12 @@ import { ScrimAbilityTimingService, ScrimAbilityTimingServiceLive, } from "@/data/scrim/ability-timing-service"; -import { - filterUtilityRoundStartSwaps, - type SwapRecord, - type SwapTimingOutcome, - type SwapWinrateBucket, -} from "@/data/team/hero-swap-service"; +import { filterUtilityRoundStartSwaps } from "@/data/team/hero-swap-service"; +import type { + SwapRecord, + SwapTimingOutcome, + SwapWinrateBucket, +} from "@/data/team/types"; import { TeamSharedDataService, TeamSharedDataServiceLive, diff --git a/src/data/scrim/scrim-service.ts b/src/data/scrim/scrim-service.ts index 46974cbbb..cc2eb2c05 100644 --- a/src/data/scrim/scrim-service.ts +++ b/src/data/scrim/scrim-service.ts @@ -47,7 +47,8 @@ import { scrimGetUserViewableScrimsSuccessTotal, } from "./metrics"; -export type Winrate = { map: string; wins: number; date: Date }[]; +export type { Winrate } from "./types"; +import type { Winrate } from "./types"; export type ScrimServiceInterface = { readonly getScrim: ( diff --git a/src/data/scrim/types.ts b/src/data/scrim/types.ts index bfd4c06e3..e278492b2 100644 --- a/src/data/scrim/types.ts +++ b/src/data/scrim/types.ts @@ -2,7 +2,7 @@ import type { TrendsAnalysis } from "@/data/comparison/types"; import type { SwapTimingOutcome, SwapWinrateBucket, -} from "@/data/team/hero-swap-service"; +} from "@/data/team/types"; import type { ValidStatColumn } from "@/lib/stat-percentiles"; import type { HeroName, RoleName } from "@/types/heroes"; import type { MapType } from "@prisma/client"; @@ -291,6 +291,9 @@ export type ScrimOverviewData = { abilityTimingAnalysis: AbilityTimingAnalysis; }; +export type Winrate = { map: string; wins: number; date: Date }[]; + +export type { PlayerUltSummary } from "./ult-helpers"; export type { SwapTimingOutcome, SwapWinrateBucket, TrendsAnalysis }; export const ScrimIdSchema = S.Number.pipe( diff --git a/src/data/team/ability-impact-service.ts b/src/data/team/ability-impact-service.ts index 29a60e44e..f56ba85e1 100644 --- a/src/data/team/ability-impact-service.ts +++ b/src/data/team/ability-impact-service.ts @@ -36,31 +36,18 @@ const abilityImpactQueryDuration = Metric.histogram( "Distribution of team ability impact query duration in milliseconds" ); -export type AbilityScenarioStats = { - fights: number; - wins: number; - losses: number; - winrate: number; -}; -export type AbilityImpactData = { - abilityName: string; - totalFightsAnalyzed: number; - scenarios: { - usedByUs: AbilityScenarioStats; - notUsedByUs: AbilityScenarioStats; - usedByEnemy: AbilityScenarioStats; - notUsedByEnemy: AbilityScenarioStats; - }; -}; -export type HeroAbilityImpact = { - hero: string; - ability1: AbilityImpactData; - ability2: AbilityImpactData; -}; -export type AbilityImpactAnalysis = { - byHero: Record; - availableHeroes: string[]; -}; +export type { + AbilityScenarioStats, + AbilityImpactData, + HeroAbilityImpact, + AbilityImpactAnalysis, +} from "./types"; +import type { + AbilityScenarioStats, + AbilityImpactData, + HeroAbilityImpact, + AbilityImpactAnalysis, +} from "./types"; function emptyScenario(): AbilityScenarioStats { return { fights: 0, wins: 0, losses: 0, winrate: 0 }; diff --git a/src/data/team/ban-impact-service.ts b/src/data/team/ban-impact-service.ts index 55a99a6e0..b09dfdec8 100644 --- a/src/data/team/ban-impact-service.ts +++ b/src/data/team/ban-impact-service.ts @@ -38,42 +38,20 @@ const banImpactQueryDuration = Metric.histogram( "Distribution of team ban impact query duration in milliseconds" ); -export type HeroBanImpact = { - hero: string; - totalBans: number; - banRate: number; - winRateWithHero: number; - winRateWithoutHero: number; - winRateDelta: number; - mapsPlayed: number; - mapsBanned: number; -}; -export type TeamBanImpactAnalysis = { - banImpacts: HeroBanImpact[]; - mostBanned: HeroBanImpact[]; - weakPoints: HeroBanImpact[]; - totalMapsAnalyzed: number; -}; -export type OurBanImpact = { - hero: string; - totalBans: number; - banRate: number; - winRateWhenBanned: number; - winRateWhenNotBanned: number; - winRateDelta: number; - mapsPlayed: number; - mapsBanned: number; -}; -export type TeamOurBanAnalysis = { - ourBanImpacts: OurBanImpact[]; - mostBannedByUs: OurBanImpact[]; - strongBans: OurBanImpact[]; - totalMapsAnalyzed: number; -}; -export type CombinedBanAnalysis = { - received: TeamBanImpactAnalysis; - outgoing: TeamOurBanAnalysis; -}; +export type { + HeroBanImpact, + TeamBanImpactAnalysis, + OurBanImpact, + TeamOurBanAnalysis, + CombinedBanAnalysis, +} from "./types"; +import type { + HeroBanImpact, + TeamBanImpactAnalysis, + OurBanImpact, + TeamOurBanAnalysis, + CombinedBanAnalysis, +} from "./types"; const MIN_BANS_FOR_SIGNIFICANCE = 3; const WEAK_POINT_DELTA_THRESHOLD = 0.15; diff --git a/src/data/team/fight-stats-service.ts b/src/data/team/fight-stats-service.ts index 1f7edf535..32d5de1fb 100644 --- a/src/data/team/fight-stats-service.ts +++ b/src/data/team/fight-stats-service.ts @@ -37,36 +37,8 @@ const fightStatsQueryDuration = Metric.histogram( "Distribution of team fight stats query duration in milliseconds" ); -export type TeamFightStats = { - totalFights: number; - fightsWon: number; - fightsLost: number; - overallWinrate: number; - firstPickFights: number; - firstPickWins: number; - firstPickWinrate: number; - firstDeathFights: number; - firstDeathWins: number; - firstDeathWinrate: number; - firstUltFights: number; - firstUltWins: number; - firstUltWinrate: number; - dryFights: number; - dryFightWins: number; - dryFightWinrate: number; - nonDryFights: number; - totalUltsInNonDryFights: number; - avgUltsPerNonDryFight: number; - dryFightReversals: number; - dryFightReversalRate: number; - nonDryFightReversals: number; - nonDryFightReversalRate: number; - ultimateEfficiency: number; - avgUltsInWonFights: number; - avgUltsInLostFights: number; - wastedUltimates: number; - totalUltsUsed: number; -}; +export type { TeamFightStats } from "./types"; +import type { TeamFightStats } from "./types"; type FightEvent = Kill & { ultimate_id?: number }; diff --git a/src/data/team/hero-pool-service.ts b/src/data/team/hero-pool-service.ts index af44c2666..02f37abfc 100644 --- a/src/data/team/hero-pool-service.ts +++ b/src/data/team/hero-pool-service.ts @@ -3,13 +3,6 @@ import prisma from "@/lib/prisma"; import { calculateWinner } from "@/lib/winrate"; import type { HeroName } from "@/types/heroes"; import { mapNameToMapTypeMapping } from "@/types/map"; -import type { - MatchStart, - ObjectiveCaptured, - PayloadProgress, - PointProgress, - RoundEnd, -} from "@prisma/client"; import { $Enums } from "@prisma/client"; import { Cache, @@ -51,67 +44,22 @@ const heroPoolQueryDuration = Metric.histogram( "Distribution of team hero pool query duration in milliseconds" ); -export type HeroPlaytime = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - totalPlaytime: number; - gamesPlayed: number; - playedBy: string[]; -}; - -export type HeroWinrate = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - totalPlaytime: number; -}; - -export type HeroSpecialist = { - playerName: string; - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - playtime: number; - gamesPlayed: number; - ownershipPercentage: number; -}; - -export type HeroDiversity = { - totalUniqueHeroes: number; - heroesPerRole: { Tank: number; Damage: number; Support: number }; - diversityScore: number; - effectiveHeroPool: number; -}; - -export type HeroPoolAnalysis = { - mostPlayedByRole: { - Tank: HeroPlaytime[]; - Damage: HeroPlaytime[]; - Support: HeroPlaytime[]; - }; - topHeroWinrates: HeroWinrate[]; - specialists: HeroSpecialist[]; - diversity: HeroDiversity; -}; - -export type HeroPoolRawData = { - teamRoster: string[]; - mapDataRecords: { id: number; name: string | null; scrimDate: Date }[]; - allPlayerStats: { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - }[]; - matchStarts: MatchStart[]; - finalRounds: RoundEnd[]; - captures: ObjectiveCaptured[]; - payloadProgresses: PayloadProgress[]; - pointProgresses: PointProgress[]; -}; +export type { + HeroPlaytime, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, +} from "./types"; +import type { + HeroPlaytime, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, +} from "./types"; function createEmptyHeroPoolAnalysis(): HeroPoolAnalysis { return { diff --git a/src/data/team/hero-swap-service.ts b/src/data/team/hero-swap-service.ts index 59f2c37b6..8b1ba6a52 100644 --- a/src/data/team/hero-swap-service.ts +++ b/src/data/team/hero-swap-service.ts @@ -1,6 +1,5 @@ import prisma from "@/lib/prisma"; import { calculateWinner } from "@/lib/winrate"; -import type { RoleName } from "@/types/heroes"; import { getHeroRole } from "@/types/heroes"; import { Cache, @@ -25,6 +24,25 @@ import { TeamSharedDataService, TeamSharedDataServiceLive, } from "./shared-data-service"; +import type { + SwapTimingBucket, + SwapWinrateBucket, + SwapPair, + PlayerSwapStats, + SwapTimingOutcome, + TeamHeroSwapStats, + SwapRecord, +} from "./types"; + +export type { + SwapTimingBucket, + SwapWinrateBucket, + SwapPair, + PlayerSwapStats, + SwapTimingOutcome, + TeamHeroSwapStats, + SwapRecord, +}; const heroSwapQuerySuccessTotal = Metric.counter( "team.hero_swap.query.success", @@ -42,75 +60,6 @@ const heroSwapQueryDuration = Metric.histogram( "Distribution of team hero swap query duration in milliseconds" ); -export type SwapTimingBucket = { - bucket: string; - count: number; - percentage: number; -}; -export type SwapWinrateBucket = { - label: string; - wins: number; - losses: number; - winrate: number; - totalMaps: number; -}; -export type SwapPair = { - fromHero: string; - toHero: string; - fromRole: RoleName; - toRole: RoleName; - count: number; - timingDistribution: SwapTimingBucket[]; -}; -export type PlayerSwapStats = { - playerName: string; - totalSwaps: number; - mapsWithSwaps: number; - mapsWithoutSwaps: number; - winrateWithSwaps: number; - winrateWithoutSwaps: number; - topSwapPair: { fromHero: string; toHero: string } | null; - topSwapPairCount: number; -}; -export type SwapTimingOutcome = { - label: string; - wins: number; - losses: number; - winrate: number; - totalMaps: number; -}; - -export type TeamHeroSwapStats = { - totalSwaps: number; - totalMaps: number; - swapsPerMap: number; - mapsWithSwaps: number; - mapsWithoutSwaps: number; - avgHeroTimeBeforeSwap: number; - noSwapWinrate: number; - noSwapWins: number; - noSwapLosses: number; - swapWinrate: number; - swapWins: number; - swapLosses: number; - timingDistribution: SwapTimingBucket[]; - winrateBySwapCount: SwapWinrateBucket[]; - topSwapPairs: SwapPair[]; - playerBreakdown: PlayerSwapStats[]; - timingOutcomes: SwapTimingOutcome[]; -}; - -export type SwapRecord = { - id: number; - match_time: number; - player_team: string; - player_name: string; - player_hero: string; - previous_hero: string; - hero_time_played: number; - MapDataId: number | null; -}; - const UTILITY_SWAP_HEROES = new Set([ "Symmetra", "Lúcio", diff --git a/src/data/team/index.ts b/src/data/team/index.ts index d0a8d4874..e0a998e34 100644 --- a/src/data/team/index.ts +++ b/src/data/team/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { TeamSharedDataService, TeamSharedDataServiceLive, @@ -23,126 +25,124 @@ export type { export { TeamNotFoundError, TeamQueryError } from "./errors"; export { TeamIdSchema, BaseTeamDataOptionsSchema } from "./types"; - -export { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; export type { - TeamStatsServiceInterface, + // hero-swap types + SwapTimingBucket, + SwapWinrateBucket, + SwapPair, + PlayerSwapStats, + SwapTimingOutcome, + TeamHeroSwapStats, + SwapRecord, + // stats types TeamWinrates, TopMapByPlaytime, BestMapByWinrate, -} from "./stats-service"; - -export { TeamTrendsService, TeamTrendsServiceLive } from "./trends-service"; -export type { - TeamTrendsServiceInterface, + // trends types WinrateDataPoint, RecentFormMatch, RecentForm, StreakInfo, -} from "./trends-service"; + // fight-stats types + TeamFightStats, + // role-stats types + RoleStats, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, + // hero-pool types + HeroPlaytime, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, + // map-mode types + MapModeStats, + MapModePerformance, + // quick-wins types + QuickWinsStats, + // ult types + ScenarioStats, + HeroUltImpact, + UltImpactAnalysis, + TeamUltRoleBreakdown, + PlayerUltRanking, + FightOpeningHero, + TeamUltStats, + // ban-impact types + HeroBanImpact, + TeamBanImpactAnalysis, + OurBanImpact, + TeamOurBanAnalysis, + CombinedBanAnalysis, + // ability-impact types + AbilityScenarioStats, + AbilityImpactData, + HeroAbilityImpact, + AbilityImpactAnalysis, + // matchup types + MapHeroEntry, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./types"; + +export { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; +export type { TeamStatsServiceInterface } from "./stats-service"; + +export { TeamTrendsService, TeamTrendsServiceLive } from "./trends-service"; +export type { TeamTrendsServiceInterface } from "./trends-service"; export { TeamFightStatsService, TeamFightStatsServiceLive, } from "./fight-stats-service"; -export type { - TeamFightStatsServiceInterface, - TeamFightStats, -} from "./fight-stats-service"; +export type { TeamFightStatsServiceInterface } from "./fight-stats-service"; export { TeamRoleStatsService, TeamRoleStatsServiceLive, } from "./role-stats-service"; -export type { - TeamRoleStatsServiceInterface, - RoleStats, - RolePerformanceStats, - RoleBalanceAnalysis, - RoleTrio, - RoleWinrateByMap, -} from "./role-stats-service"; +export type { TeamRoleStatsServiceInterface } from "./role-stats-service"; export { TeamHeroPoolService, TeamHeroPoolServiceLive, } from "./hero-pool-service"; -export type { - TeamHeroPoolServiceInterface, - HeroPlaytime, - HeroWinrate, - HeroSpecialist, - HeroDiversity, - HeroPoolAnalysis, - HeroPoolRawData, -} from "./hero-pool-service"; +export type { TeamHeroPoolServiceInterface } from "./hero-pool-service"; export { TeamHeroSwapService, TeamHeroSwapServiceLive, } from "./hero-swap-service"; -export type { - TeamHeroSwapServiceInterface, - TeamHeroSwapStats, - SwapTimingBucket, - SwapWinrateBucket, - SwapPair, - PlayerSwapStats, - SwapTimingOutcome, -} from "./hero-swap-service"; +export type { TeamHeroSwapServiceInterface } from "./hero-swap-service"; export { TeamMapModeService, TeamMapModeServiceLive } from "./map-mode-service"; -export type { - TeamMapModeServiceInterface, - MapModeStats, - MapModePerformance, -} from "./map-mode-service"; +export type { TeamMapModeServiceInterface } from "./map-mode-service"; export { TeamQuickWinsService, TeamQuickWinsServiceLive, } from "./quick-wins-service"; -export type { - TeamQuickWinsServiceInterface, - QuickWinsStats, -} from "./quick-wins-service"; +export type { TeamQuickWinsServiceInterface } from "./quick-wins-service"; export { TeamUltService, TeamUltServiceLive } from "./ult-service"; -export type { - TeamUltServiceInterface, - ScenarioStats, - HeroUltImpact, - UltImpactAnalysis, - TeamUltRoleBreakdown, - PlayerUltRanking, - FightOpeningHero, - TeamUltStats, -} from "./ult-service"; +export type { TeamUltServiceInterface } from "./ult-service"; export { TeamBanImpactService, TeamBanImpactServiceLive, } from "./ban-impact-service"; -export type { - TeamBanImpactServiceInterface, - HeroBanImpact, - TeamBanImpactAnalysis, - OurBanImpact, - TeamOurBanAnalysis, - CombinedBanAnalysis, -} from "./ban-impact-service"; +export type { TeamBanImpactServiceInterface } from "./ban-impact-service"; export { TeamAbilityImpactService, TeamAbilityImpactServiceLive, } from "./ability-impact-service"; -export type { - TeamAbilityImpactServiceInterface, - AbilityScenarioStats, - AbilityImpactData, - HeroAbilityImpact, - AbilityImpactAnalysis, -} from "./ability-impact-service"; +export type { TeamAbilityImpactServiceInterface } from "./ability-impact-service"; export { TeamComparisonService, @@ -151,11 +151,4 @@ export { export type { TeamComparisonServiceInterface } from "./comparison-service"; export { TeamMatchupService, TeamMatchupServiceLive } from "./matchup-service"; -export type { - TeamMatchupServiceInterface, - MapHeroEntry, - MatchupMapResult, - MatchupWinrateData, - EnemyHeroWinrate, - EnemyHeroAnalysis, -} from "./matchup-service"; +export type { TeamMatchupServiceInterface } from "./matchup-service"; diff --git a/src/data/team/map-mode-service.ts b/src/data/team/map-mode-service.ts index 807091611..9701c42b8 100644 --- a/src/data/team/map-mode-service.ts +++ b/src/data/team/map-mode-service.ts @@ -42,28 +42,8 @@ const mapModeQueryDuration = Metric.histogram( "Distribution of team map mode query duration in milliseconds" ); -export type MapModeStats = { - mapType: $Enums.MapType; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - avgPlaytime: number; - bestMap: { name: string; winrate: number } | null; - worstMap: { name: string; winrate: number } | null; -}; - -export type MapModePerformance = { - overall: { - totalGames: number; - totalWins: number; - totalLosses: number; - overallWinrate: number; - }; - byMode: Record<$Enums.MapType, MapModeStats>; - bestMode: $Enums.MapType | null; - worstMode: $Enums.MapType | null; -}; +export type { MapModeStats, MapModePerformance } from "./types"; +import type { MapModeStats, MapModePerformance } from "./types"; function createEmptyMapModePerformance(): MapModePerformance { const emptyStats: MapModeStats = { diff --git a/src/data/team/matchup-service.ts b/src/data/team/matchup-service.ts index c1cf7c092..cf3c8f466 100644 --- a/src/data/team/matchup-service.ts +++ b/src/data/team/matchup-service.ts @@ -39,40 +39,20 @@ const matchupQueryDuration = Metric.histogram( "Distribution of team matchup query duration in milliseconds" ); -export type MapHeroEntry = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - playerName: string; - timePlayed: number; -}; - -export type MatchupMapResult = { - mapDataId: number; - mapName: string; - scrimName: string; - date: string; - isWin: boolean; - ourHeroes: MapHeroEntry[]; - enemyHeroes: MapHeroEntry[]; -}; - -export type MatchupWinrateData = { - maps: MatchupMapResult[]; - allOurHeroes: HeroName[]; - allEnemyHeroes: HeroName[]; -}; - -export type EnemyHeroWinrate = { - heroName: HeroName; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; -}; - -export type EnemyHeroAnalysis = { - winrateVsHero: EnemyHeroWinrate[]; -}; +export type { + MapHeroEntry, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./types"; +import type { + MapHeroEntry, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./types"; const MIN_GAMES_FOR_INCLUSION = 2; diff --git a/src/data/team/quick-wins-service.ts b/src/data/team/quick-wins-service.ts index 7350012fa..4efe8736e 100644 --- a/src/data/team/quick-wins-service.ts +++ b/src/data/team/quick-wins-service.ts @@ -46,22 +46,8 @@ const quickWinsQueryDuration = Metric.histogram( "Distribution of team quick wins query duration in milliseconds" ); -export type QuickWinsStats = { - last10GamesPerformance: { wins: number; losses: number; winrate: number }; - bestDayOfWeek: { - day: string; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - } | null; - averageFightDuration: number | null; - firstPickSuccessRate: { - successfulFirstPicks: number; - totalFirstPicks: number; - successRate: number; - } | null; -}; +export type { QuickWinsStats } from "./types"; +import type { QuickWinsStats } from "./types"; type FightEvent = Kill & { ultimate_id?: number }; type Fight = { events: FightEvent[]; start: number; end: number }; diff --git a/src/data/team/role-stats-service.ts b/src/data/team/role-stats-service.ts index 256300994..0b73e7f6e 100644 --- a/src/data/team/role-stats-service.ts +++ b/src/data/team/role-stats-service.ts @@ -42,58 +42,20 @@ const roleStatsQueryDuration = Metric.histogram( "Distribution of team role stats query duration in milliseconds" ); -export type RoleStats = { - role: "Tank" | "Damage" | "Support"; - totalPlaytime: number; - mapCount: number; - eliminations: number; - finalBlows: number; - deaths: number; - assists: number; - heroDamage: number; - damageTaken: number; - healing: number; - ultimatesEarned: number; - ultimatesUsed: number; - kd: number; - damagePer10Min: number; - healingPer10Min: number; - deathsPer10Min: number; - ultEfficiency: number; -}; - -export type RolePerformanceStats = { - Tank: RoleStats; - Damage: RoleStats; - Support: RoleStats; -}; - -export type RoleBalanceAnalysis = { - overall: string; - weakestRole: "Tank" | "Damage" | "Support" | null; - strongestRole: "Tank" | "Damage" | "Support" | null; - balanceScore: number; - insights: string[]; -}; - -export type RoleTrio = { - tank: string; - dps1: string; - dps2: string; - support1: string; - support2: string; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; -}; - -export type RoleWinrateByMap = { - mapName: string; - Tank: { wins: number; losses: number; winrate: number }; - Damage: { wins: number; losses: number; winrate: number }; - Support: { wins: number; losses: number; winrate: number }; -}; +export type { + RoleStats, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, +} from "./types"; +import type { + RoleStats, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, +} from "./types"; function createEmptyRoleStats(): RolePerformanceStats { const emptyStats: RoleStats = { diff --git a/src/data/team/stats-service.ts b/src/data/team/stats-service.ts index a9a8d6019..be5aea52c 100644 --- a/src/data/team/stats-service.ts +++ b/src/data/team/stats-service.ts @@ -57,23 +57,8 @@ type MapWinrate = { bestWinrate: number; }; -export type TeamWinrates = { - overallWins: number; - overallLosses: number; - overallWinrate: number; - byMap: Record; -}; - -export type TopMapByPlaytime = { - name: string; - playtime: number; -}; - -export type BestMapByWinrate = { - mapName: string; - playtime: number; - winrate: number; -}; +export type { TeamWinrates, TopMapByPlaytime, BestMapByWinrate } from "./types"; +import type { TeamWinrates, TopMapByPlaytime, BestMapByWinrate } from "./types"; function getRosterForMap( mapDataId: number, diff --git a/src/data/team/trends-service.ts b/src/data/team/trends-service.ts index 5f11117db..c13a2fb95 100644 --- a/src/data/team/trends-service.ts +++ b/src/data/team/trends-service.ts @@ -42,47 +42,18 @@ const trendsQueryDuration = Metric.histogram( "Distribution of team trends query duration in milliseconds" ); -export type WinrateDataPoint = { - date: Date; - winrate: number; - wins: number; - losses: number; - period: string; -}; - -export type RecentFormMatch = { - scrimId: number; - scrimName: string; - date: Date; - mapName: string; - result: "win" | "loss"; -}; - -export type RecentForm = { - last5: RecentFormMatch[]; - last10: RecentFormMatch[]; - last20: RecentFormMatch[]; - last5Winrate: number; - last10Winrate: number; - last20Winrate: number; -}; - -export type StreakInfo = { - currentStreak: { - type: "win" | "loss" | "none"; - count: number; - }; - longestWinStreak: { - count: number; - startDate: Date | null; - endDate: Date | null; - }; - longestLossStreak: { - count: number; - startDate: Date | null; - endDate: Date | null; - }; -}; +export type { + WinrateDataPoint, + RecentFormMatch, + RecentForm, + StreakInfo, +} from "./types"; +import type { + WinrateDataPoint, + RecentFormMatch, + RecentForm, + StreakInfo, +} from "./types"; type ProcessedMatchResult = { scrimId: number; diff --git a/src/data/team/types.ts b/src/data/team/types.ts index e3c76bb72..2c22b3c34 100644 --- a/src/data/team/types.ts +++ b/src/data/team/types.ts @@ -1,4 +1,15 @@ +import type { RoleName } from "@/types/heroes"; +import type { HeroName } from "@/types/heroes"; +import type { + MatchStart, + ObjectiveCaptured, + PayloadProgress, + PointProgress, + RoundEnd, +} from "@prisma/client"; +import type { $Enums } from "@prisma/client"; import { Schema as S } from "effect"; +import type { SubroleUltTiming } from "@/data/scrim/ult-helpers"; export const TeamIdSchema = S.Number.pipe( S.int(), @@ -20,3 +31,513 @@ export const BaseTeamDataOptionsSchema = S.Struct({ export type BaseTeamDataOptions = S.Schema.Type< typeof BaseTeamDataOptionsSchema >; + +// ---------- hero-swap-service types ---------- + +export type SwapTimingBucket = { + bucket: string; + count: number; + percentage: number; +}; +export type SwapWinrateBucket = { + label: string; + wins: number; + losses: number; + winrate: number; + totalMaps: number; +}; +export type SwapPair = { + fromHero: string; + toHero: string; + fromRole: RoleName; + toRole: RoleName; + count: number; + timingDistribution: SwapTimingBucket[]; +}; +export type PlayerSwapStats = { + playerName: string; + totalSwaps: number; + mapsWithSwaps: number; + mapsWithoutSwaps: number; + winrateWithSwaps: number; + winrateWithoutSwaps: number; + topSwapPair: { fromHero: string; toHero: string } | null; + topSwapPairCount: number; +}; +export type SwapTimingOutcome = { + label: string; + wins: number; + losses: number; + winrate: number; + totalMaps: number; +}; + +export type TeamHeroSwapStats = { + totalSwaps: number; + totalMaps: number; + swapsPerMap: number; + mapsWithSwaps: number; + mapsWithoutSwaps: number; + avgHeroTimeBeforeSwap: number; + noSwapWinrate: number; + noSwapWins: number; + noSwapLosses: number; + swapWinrate: number; + swapWins: number; + swapLosses: number; + timingDistribution: SwapTimingBucket[]; + winrateBySwapCount: SwapWinrateBucket[]; + topSwapPairs: SwapPair[]; + playerBreakdown: PlayerSwapStats[]; + timingOutcomes: SwapTimingOutcome[]; +}; + +export type SwapRecord = { + id: number; + match_time: number; + player_team: string; + player_name: string; + player_hero: string; + previous_hero: string; + hero_time_played: number; + MapDataId: number | null; +}; + +// ---------- stats-service types ---------- + +export type TeamWinrates = { + overallWins: number; + overallLosses: number; + overallWinrate: number; + byMap: Record< + string, + { + mapName: string; + totalWins: number; + totalLosses: number; + totalWinrate: number; + rosterVariants: { + players: string[]; + wins: number; + losses: number; + winrate: number; + }[]; + bestRoster: string[] | null; + bestWinrate: number; + } + >; +}; + +export type TopMapByPlaytime = { + name: string; + playtime: number; +}; + +export type BestMapByWinrate = { + mapName: string; + playtime: number; + winrate: number; +}; + +// ---------- trends-service types ---------- + +export type WinrateDataPoint = { + date: Date; + winrate: number; + wins: number; + losses: number; + period: string; +}; + +export type RecentFormMatch = { + scrimId: number; + scrimName: string; + date: Date; + mapName: string; + result: "win" | "loss"; +}; + +export type RecentForm = { + last5: RecentFormMatch[]; + last10: RecentFormMatch[]; + last20: RecentFormMatch[]; + last5Winrate: number; + last10Winrate: number; + last20Winrate: number; +}; + +export type StreakInfo = { + currentStreak: { + type: "win" | "loss" | "none"; + count: number; + }; + longestWinStreak: { + count: number; + startDate: Date | null; + endDate: Date | null; + }; + longestLossStreak: { + count: number; + startDate: Date | null; + endDate: Date | null; + }; +}; + +// ---------- fight-stats-service types ---------- + +export type TeamFightStats = { + totalFights: number; + fightsWon: number; + fightsLost: number; + overallWinrate: number; + firstPickFights: number; + firstPickWins: number; + firstPickWinrate: number; + firstDeathFights: number; + firstDeathWins: number; + firstDeathWinrate: number; + firstUltFights: number; + firstUltWins: number; + firstUltWinrate: number; + dryFights: number; + dryFightWins: number; + dryFightWinrate: number; + nonDryFights: number; + totalUltsInNonDryFights: number; + avgUltsPerNonDryFight: number; + dryFightReversals: number; + dryFightReversalRate: number; + nonDryFightReversals: number; + nonDryFightReversalRate: number; + ultimateEfficiency: number; + avgUltsInWonFights: number; + avgUltsInLostFights: number; + wastedUltimates: number; + totalUltsUsed: number; +}; + +// ---------- role-stats-service types ---------- + +export type RoleStats = { + role: "Tank" | "Damage" | "Support"; + totalPlaytime: number; + mapCount: number; + eliminations: number; + finalBlows: number; + deaths: number; + assists: number; + heroDamage: number; + damageTaken: number; + healing: number; + ultimatesEarned: number; + ultimatesUsed: number; + kd: number; + damagePer10Min: number; + healingPer10Min: number; + deathsPer10Min: number; + ultEfficiency: number; +}; + +export type RolePerformanceStats = { + Tank: RoleStats; + Damage: RoleStats; + Support: RoleStats; +}; + +export type RoleBalanceAnalysis = { + overall: string; + weakestRole: "Tank" | "Damage" | "Support" | null; + strongestRole: "Tank" | "Damage" | "Support" | null; + balanceScore: number; + insights: string[]; +}; + +export type RoleTrio = { + tank: string; + dps1: string; + dps2: string; + support1: string; + support2: string; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; +}; + +export type RoleWinrateByMap = { + mapName: string; + Tank: { wins: number; losses: number; winrate: number }; + Damage: { wins: number; losses: number; winrate: number }; + Support: { wins: number; losses: number; winrate: number }; +}; + +// ---------- hero-pool-service types ---------- + +export type HeroPlaytime = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + totalPlaytime: number; + gamesPlayed: number; + playedBy: string[]; +}; + +export type HeroWinrate = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + totalPlaytime: number; +}; + +export type HeroSpecialist = { + playerName: string; + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + playtime: number; + gamesPlayed: number; + ownershipPercentage: number; +}; + +export type HeroDiversity = { + totalUniqueHeroes: number; + heroesPerRole: { Tank: number; Damage: number; Support: number }; + diversityScore: number; + effectiveHeroPool: number; +}; + +export type HeroPoolAnalysis = { + mostPlayedByRole: { + Tank: HeroPlaytime[]; + Damage: HeroPlaytime[]; + Support: HeroPlaytime[]; + }; + topHeroWinrates: HeroWinrate[]; + specialists: HeroSpecialist[]; + diversity: HeroDiversity; +}; + +export type HeroPoolRawData = { + teamRoster: string[]; + mapDataRecords: { id: number; name: string | null; scrimDate: Date }[]; + allPlayerStats: { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + }[]; + matchStarts: MatchStart[]; + finalRounds: RoundEnd[]; + captures: ObjectiveCaptured[]; + payloadProgresses: PayloadProgress[]; + pointProgresses: PointProgress[]; +}; + +// ---------- map-mode-service types ---------- + +export type MapModeStats = { + mapType: $Enums.MapType; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + avgPlaytime: number; + bestMap: { name: string; winrate: number } | null; + worstMap: { name: string; winrate: number } | null; +}; + +export type MapModePerformance = { + overall: { + totalGames: number; + totalWins: number; + totalLosses: number; + overallWinrate: number; + }; + byMode: Record<$Enums.MapType, MapModeStats>; + bestMode: $Enums.MapType | null; + worstMode: $Enums.MapType | null; +}; + +// ---------- quick-wins-service types ---------- + +export type QuickWinsStats = { + last10GamesPerformance: { wins: number; losses: number; winrate: number }; + bestDayOfWeek: { + day: string; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; + } | null; + averageFightDuration: number | null; + firstPickSuccessRate: { + successfulFirstPicks: number; + totalFirstPicks: number; + successRate: number; + } | null; +}; + +// ---------- ult-service types ---------- + +export type ScenarioStats = { + fights: number; + wins: number; + losses: number; + winrate: number; +}; + +export type HeroUltImpact = { + hero: string; + totalFightsAnalyzed: number; + scenarios: { + uncontestedOurs: ScenarioStats; + uncontestedTheirs: ScenarioStats; + mirrorOursFirst: ScenarioStats; + mirrorTheirsFirst: ScenarioStats; + }; +}; + +export type UltImpactAnalysis = { + byHero: Record; + availableHeroes: string[]; +}; + +export type TeamUltRoleBreakdown = { + role: RoleName; + count: number; + percentage: number; + subroleTimings: SubroleUltTiming[]; +}; + +export type PlayerUltRanking = { + playerName: string; + primaryHero: string; + totalUltsUsed: number; + mapsPlayed: number; + ultsPerMap: number; + topFightOpeningHero: string | null; + fightOpeningCount: number; +}; + +export type FightOpeningHero = { hero: string; count: number }; + +export type TeamUltStats = { + totalUltsUsed: number; + totalUltsEarned: number; + totalMaps: number; + ultsPerMap: number; + avgChargeTime: number; + avgHoldTime: number; + fightInitiationRate: number; + fightInitiationCount: number; + totalFightsWithUlts: number; + topFightOpeningHeroes: FightOpeningHero[]; + roleBreakdown: TeamUltRoleBreakdown[]; + playerRankings: PlayerUltRanking[]; +}; + +// ---------- ban-impact-service types ---------- + +export type HeroBanImpact = { + hero: string; + totalBans: number; + banRate: number; + winRateWithHero: number; + winRateWithoutHero: number; + winRateDelta: number; + mapsPlayed: number; + mapsBanned: number; +}; +export type TeamBanImpactAnalysis = { + banImpacts: HeroBanImpact[]; + mostBanned: HeroBanImpact[]; + weakPoints: HeroBanImpact[]; + totalMapsAnalyzed: number; +}; +export type OurBanImpact = { + hero: string; + totalBans: number; + banRate: number; + winRateWhenBanned: number; + winRateWhenNotBanned: number; + winRateDelta: number; + mapsPlayed: number; + mapsBanned: number; +}; +export type TeamOurBanAnalysis = { + ourBanImpacts: OurBanImpact[]; + mostBannedByUs: OurBanImpact[]; + strongBans: OurBanImpact[]; + totalMapsAnalyzed: number; +}; +export type CombinedBanAnalysis = { + received: TeamBanImpactAnalysis; + outgoing: TeamOurBanAnalysis; +}; + +// ---------- ability-impact-service types ---------- + +export type AbilityScenarioStats = { + fights: number; + wins: number; + losses: number; + winrate: number; +}; +export type AbilityImpactData = { + abilityName: string; + totalFightsAnalyzed: number; + scenarios: { + usedByUs: AbilityScenarioStats; + notUsedByUs: AbilityScenarioStats; + usedByEnemy: AbilityScenarioStats; + notUsedByEnemy: AbilityScenarioStats; + }; +}; +export type HeroAbilityImpact = { + hero: string; + ability1: AbilityImpactData; + ability2: AbilityImpactData; +}; +export type AbilityImpactAnalysis = { + byHero: Record; + availableHeroes: string[]; +}; + +// ---------- matchup-service types ---------- + +export type MapHeroEntry = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + playerName: string; + timePlayed: number; +}; + +export type MatchupMapResult = { + mapDataId: number; + mapName: string; + scrimName: string; + date: string; + isWin: boolean; + ourHeroes: MapHeroEntry[]; + enemyHeroes: MapHeroEntry[]; +}; + +export type MatchupWinrateData = { + maps: MatchupMapResult[]; + allOurHeroes: HeroName[]; + allEnemyHeroes: HeroName[]; +}; + +export type EnemyHeroWinrate = { + heroName: HeroName; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; +}; + +export type EnemyHeroAnalysis = { + winrateVsHero: EnemyHeroWinrate[]; +}; diff --git a/src/data/team/ult-service.ts b/src/data/team/ult-service.ts index f8e6ff2c6..fa1c6ff3c 100644 --- a/src/data/team/ult-service.ts +++ b/src/data/team/ult-service.ts @@ -52,62 +52,24 @@ const ultQueryDuration = Metric.histogram( "Distribution of team ult query duration in milliseconds" ); -export type ScenarioStats = { - fights: number; - wins: number; - losses: number; - winrate: number; -}; - -export type HeroUltImpact = { - hero: string; - totalFightsAnalyzed: number; - scenarios: { - uncontestedOurs: ScenarioStats; - uncontestedTheirs: ScenarioStats; - mirrorOursFirst: ScenarioStats; - mirrorTheirsFirst: ScenarioStats; - }; -}; - -export type UltImpactAnalysis = { - byHero: Record; - availableHeroes: string[]; -}; - -export type TeamUltRoleBreakdown = { - role: RoleName; - count: number; - percentage: number; - subroleTimings: SubroleUltTiming[]; -}; - -export type PlayerUltRanking = { - playerName: string; - primaryHero: string; - totalUltsUsed: number; - mapsPlayed: number; - ultsPerMap: number; - topFightOpeningHero: string | null; - fightOpeningCount: number; -}; - -export type FightOpeningHero = { hero: string; count: number }; - -export type TeamUltStats = { - totalUltsUsed: number; - totalUltsEarned: number; - totalMaps: number; - ultsPerMap: number; - avgChargeTime: number; - avgHoldTime: number; - fightInitiationRate: number; - fightInitiationCount: number; - totalFightsWithUlts: number; - topFightOpeningHeroes: FightOpeningHero[]; - roleBreakdown: TeamUltRoleBreakdown[]; - playerRankings: PlayerUltRanking[]; -}; +export type { + ScenarioStats, + HeroUltImpact, + UltImpactAnalysis, + TeamUltRoleBreakdown, + PlayerUltRanking, + FightOpeningHero, + TeamUltStats, +} from "./types"; +import type { + ScenarioStats, + HeroUltImpact, + UltImpactAnalysis, + TeamUltRoleBreakdown, + PlayerUltRanking, + FightOpeningHero, + TeamUltStats, +} from "./types"; function emptyScenario(): ScenarioStats { return { fights: 0, wins: 0, losses: 0, winrate: 0 }; diff --git a/src/data/tournament/index.ts b/src/data/tournament/index.ts index bc7b51dba..3460facbb 100644 --- a/src/data/tournament/index.ts +++ b/src/data/tournament/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { TournamentService, TournamentServiceLive } from "./tournament-service"; export type { TournamentServiceInterface } from "./tournament-service"; diff --git a/src/data/user/index.ts b/src/data/user/index.ts index 7f9766a89..0f02c09e6 100644 --- a/src/data/user/index.ts +++ b/src/data/user/index.ts @@ -1,3 +1,5 @@ +import "server-only"; + export { UserService, UserServiceLive } from "./service"; export type { UserServiceInterface } from "./service"; From fb9057c8abc56c88cc4c6f18efe03d2799cc6027 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:21:49 -0400 Subject: [PATCH 22/42] Add team analytics and prediction Effect services Migrate team-analytics-dto (3 functions: getHeroPickrateMatrix, getPlayerMapPerformanceMatrix, getHeroPickrateRawData) and team-prediction-dto (1 function: getSimulatorContext) to Effect services. Prediction service composes 6 team services via Effect.all for parallel execution. Both added to DataLayerLive. --- src/data/layer.ts | 6 +- src/data/team/analytics-service.ts | 768 ++++++++++++++++++++++++++++ src/data/team/prediction-service.ts | 292 +++++++++++ 3 files changed, 1065 insertions(+), 1 deletion(-) create mode 100644 src/data/team/analytics-service.ts create mode 100644 src/data/team/prediction-service.ts diff --git a/src/data/layer.ts b/src/data/layer.ts index 4f0ffde12..7b222a22c 100644 --- a/src/data/layer.ts +++ b/src/data/layer.ts @@ -31,7 +31,9 @@ import { TeamFightStatsServiceLive } from "./team/fight-stats-service"; import { TeamHeroPoolServiceLive } from "./team/hero-pool-service"; import { TeamHeroSwapServiceLive } from "./team/hero-swap-service"; import { TeamMapModeServiceLive } from "./team/map-mode-service"; +import { TeamAnalyticsServiceLive } from "./team/analytics-service"; import { TeamMatchupServiceLive } from "./team/matchup-service"; +import { TeamPredictionServiceLive } from "./team/prediction-service"; import { TeamQuickWinsServiceLive } from "./team/quick-wins-service"; import { TeamRoleStatsServiceLive } from "./team/role-stats-service"; import { TeamSharedDataServiceLive } from "./team/shared-data-service"; @@ -90,5 +92,7 @@ export const DataLayerLive = Layer.mergeAll( TeamBanImpactServiceLive, TeamAbilityImpactServiceLive, TeamComparisonServiceLive, - TeamMatchupServiceLive + TeamMatchupServiceLive, + TeamAnalyticsServiceLive, + TeamPredictionServiceLive ); diff --git a/src/data/team/analytics-service.ts b/src/data/team/analytics-service.ts new file mode 100644 index 000000000..c1b710b9d --- /dev/null +++ b/src/data/team/analytics-service.ts @@ -0,0 +1,768 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { determineRole } from "@/lib/player-table-data"; +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +import type { HeroName } from "@/types/heroes"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import { teamCacheMissTotal, teamCacheRequestTotal } from "./metrics"; +import type { TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, +} from "./shared-core"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; + +const analyticsQuerySuccessTotal = Metric.counter( + "team.analytics.query.success", + { + description: "Total successful team analytics queries", + incremental: true, + } +); + +const analyticsQueryErrorTotal = Metric.counter("team.analytics.query.error", { + description: "Total team analytics query failures", + incremental: true, +}); + +const analyticsQueryDuration = Metric.histogram( + "team.analytics.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team analytics query duration in milliseconds" +); + +export type { + HeroPickrate, + PlayerHeroData, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerMapPerformance, + PlayerMapPerformanceMatrix, +} from "./types"; +import type { + HeroPickrate, + PlayerHeroData, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerMapPerformance, + PlayerMapPerformanceMatrix, +} from "./types"; + +export type TeamAnalyticsServiceInterface = { + readonly getHeroPickrateMatrix: ( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ) => Effect.Effect; + + readonly getPlayerMapPerformanceMatrix: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; + + readonly getHeroPickrateRawData: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamAnalyticsService extends Context.Tag( + "@app/data/team/TeamAnalyticsService" +)() {} + +function buildPlayerHeroData( + mapDataRecords: { id: number; name: string | null }[], + allPlayerStats: { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + }[], + teamRosterSet: Set +): HeroPickrateMatrix { + const playerHeroMap = new Map< + string, + Map }> + >(); + const allHeroesSet = new Set(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName + ); + + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; + + for (const stat of playersOnMap) { + const playerName = stat.player_name; + const heroName = stat.player_hero as HeroName; + + if (!playerHeroMap.has(playerName)) { + playerHeroMap.set(playerName, new Map()); + } + + const playerHeroes = playerHeroMap.get(playerName)!; + if (!playerHeroes.has(heroName)) { + playerHeroes.set(heroName, { playtime: 0, games: new Set() }); + } + + const heroData = playerHeroes.get(heroName)!; + heroData.playtime += stat.hero_time_played; + heroData.games.add(mapDataId); + + allHeroesSet.add(heroName); + } + } + + const players: PlayerHeroData[] = []; + + for (const [playerName, heroesMap] of playerHeroMap.entries()) { + let totalPlaytime = 0; + const heroes: HeroPickrate[] = []; + + for (const [heroName, data] of heroesMap.entries()) { + const role = determineRole(heroName); + if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; + + totalPlaytime += data.playtime; + heroes.push({ + heroName, + role, + playtime: data.playtime, + gamesPlayed: data.games.size, + }); + } + + heroes.sort((a, b) => b.playtime - a.playtime); + + players.push({ + playerName, + heroes, + totalPlaytime, + }); + } + + players.sort((a, b) => b.totalPlaytime - a.totalPlaytime); + + return { + players, + allHeroes: Array.from(allHeroesSet), + }; +} + +export const make = Effect.gen(function* () { + const shared = yield* TeamSharedDataService; + + function getHeroPickrateMatrix( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!(dateFrom && dateTo), + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + + // Special handling for date range filtering - need custom query + if (dateFrom && dateTo) { + const teamRoster = yield* shared.getTeamRoster(teamId); + const teamRosterSet = new Set(teamRoster); + + if (teamRoster.length === 0) { + wideEvent.outcome = "success"; + wideEvent.player_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { players: [], allHeroes: [] } as HeroPickrateMatrix; + } + + const dateFilter = { + date: { gte: dateFrom, lte: dateTo }, + }; + + const allMapDataRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { + Scrim: { + Team: { id: teamId }, + ...dateFilter, + }, + }, + select: { id: true, name: true }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch map records for pickrate with date range", + cause: error, + }), + }); + + const mapDataRecords = allMapDataRecords.filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + return ( + mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash + ); + }); + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.player_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { players: [], allHeroes: [] } as HeroPickrateMatrix; + } + + const mapDataIds = mapDataRecords.map((md) => md.id); + + const allPlayerStats = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + MapDataId: true, + eliminations: true, + final_blows: true, + deaths: true, + offensive_assists: true, + hero_damage_dealt: true, + damage_taken: true, + healing_dealt: true, + ultimates_earned: true, + ultimates_used: true, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch player stats for pickrate with date range", + cause: error, + }), + }); + + const result = buildPlayerHeroData( + mapDataRecords, + allPlayerStats, + teamRosterSet + ); + wideEvent.outcome = "success"; + wideEvent.player_count = result.players.length; + yield* Metric.increment(analyticsQuerySuccessTotal); + return result; + } + + // No date range - use shared data + const sharedData = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + excludeClash: true, + }); + + if (sharedData.mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.player_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { players: [], allHeroes: [] } as HeroPickrateMatrix; + } + + const result = buildPlayerHeroData( + sharedData.mapDataRecords, + sharedData.allPlayerStats, + sharedData.teamRosterSet + ); + wideEvent.outcome = "success"; + wideEvent.player_count = result.players.length; + yield* Metric.increment(analyticsQuerySuccessTotal); + return result; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(analyticsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.analytics.getHeroPickrateMatrix") + : Effect.logInfo("team.analytics.getHeroPickrateMatrix"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(analyticsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.analytics.getHeroPickrateMatrix") + ); + } + + function getPlayerMapPerformanceMatrix( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const sharedData = yield* shared.getBaseTeamData(teamId, { + excludePush: true, + excludeClash: true, + dateRange, + }); + + if (sharedData.mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.player_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { + players: [], + maps: [], + performance: [], + } as PlayerMapPerformanceMatrix; + } + + const { + teamRosterSet, + mapDataRecords, + allPlayerStats, + matchStarts, + finalRounds, + captures, + payloadProgresses, + pointProgresses, + } = sharedData; + + // Build lookup maps + const finalRoundMap = buildFinalRoundMap(finalRounds); + const matchStartMap = buildMatchStartMap(matchStarts); + + const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( + captures, + matchStartMap + ); + const { + team1ProgressMap: team1PayloadProgressMap, + team2ProgressMap: team2PayloadProgressMap, + } = buildProgressMaps(payloadProgresses, matchStartMap); + const { + team1ProgressMap: team1PointProgressMap, + team2ProgressMap: team2PointProgressMap, + } = buildProgressMaps(pointProgresses, matchStartMap); + + type MapData = { + players: Set; + wins: number; + losses: number; + }; + + const playerMapStats = new Map>(); + + for (const mapDataRecord of mapDataRecords) { + const mapDataId = mapDataRecord.id; + const mapName = mapDataRecord.name; + if (!mapName) continue; + + const teamName = findTeamNameForMapInMemory( + mapDataId, + allPlayerStats, + teamRosterSet + ); + if (!teamName) continue; + + const playersOnMap = allPlayerStats.filter( + (stat) => + stat.MapDataId === mapDataId && stat.player_team === teamName + ); + + if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) + continue; + + const matchDetails = matchStartMap.get(mapDataId) ?? null; + const finalRound = finalRoundMap.get(mapDataId) ?? null; + const winner = calculateWinner({ + matchDetails, + finalRound, + team1Captures: team1CapturesMap.get(mapDataId) ?? [], + team2Captures: team2CapturesMap.get(mapDataId) ?? [], + team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], + team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], + team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], + team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], + }); + + const isWin = winner === teamName; + + const uniquePlayersOnMap = new Set(); + for (const stat of playersOnMap) { + uniquePlayersOnMap.add(stat.player_name); + } + + for (const playerName of uniquePlayersOnMap) { + if (!playerMapStats.has(playerName)) { + playerMapStats.set(playerName, new Map()); + } + + const playerMaps = playerMapStats.get(playerName)!; + if (!playerMaps.has(mapName)) { + playerMaps.set(mapName, { + players: new Set(), + wins: 0, + losses: 0, + }); + } + + const mapData = playerMaps.get(mapName)!; + mapData.players.add(playerName); + if (isWin) { + mapData.wins++; + } else { + mapData.losses++; + } + } + } + + const performance: PlayerMapPerformance[] = []; + const uniquePlayers = new Set(); + const uniqueMaps = new Set(); + + for (const [playerName, mapsData] of playerMapStats.entries()) { + uniquePlayers.add(playerName); + + for (const [mapName, data] of mapsData.entries()) { + uniqueMaps.add(mapName); + const gamesPlayed = data.wins + data.losses; + const winrate = + gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; + + performance.push({ + playerName, + mapName, + wins: data.wins, + losses: data.losses, + winrate, + gamesPlayed, + }); + } + } + + wideEvent.outcome = "success"; + wideEvent.player_count = uniquePlayers.size; + wideEvent.map_count = uniqueMaps.size; + yield* Metric.increment(analyticsQuerySuccessTotal); + + return { + players: Array.from(uniquePlayers).sort(), + maps: Array.from(uniqueMaps).sort(), + performance, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(analyticsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError( + "team.analytics.getPlayerMapPerformanceMatrix" + ) + : Effect.logInfo( + "team.analytics.getPlayerMapPerformanceMatrix" + ); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(analyticsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.analytics.getPlayerMapPerformanceMatrix") + ); + } + + function getHeroPickrateRawData( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + const teamRoster = yield* shared.getTeamRoster(teamId); + + if (teamRoster.length === 0) { + wideEvent.outcome = "success"; + wideEvent.map_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { + teamRoster: [], + mapDataRecords: [], + allPlayerStats: [], + } as HeroPickrateRawData; + } + + const scrimWhereClause: Record = { + Team: { id: teamId }, + }; + if (dateRange) { + scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; + } + + const allMapDataRecords = yield* Effect.tryPromise({ + try: () => + prisma.map.findMany({ + where: { Scrim: scrimWhereClause }, + select: { + id: true, + name: true, + Scrim: { + select: { + date: true, + }, + }, + }, + orderBy: { + Scrim: { + date: "desc", + }, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch map records for raw hero pickrate data", + cause: error, + }), + }); + + const mapDataRecords = allMapDataRecords + .filter((record) => { + const mapName = record.name; + if (!mapName) return false; + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + return ( + mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash + ); + }) + .map((record) => ({ + id: record.id, + name: record.name, + scrimDate: record.Scrim?.date ?? new Date(), + })); + + if (mapDataRecords.length === 0) { + wideEvent.outcome = "success"; + wideEvent.map_count = 0; + yield* Metric.increment(analyticsQuerySuccessTotal); + return { + teamRoster, + mapDataRecords: [], + allPlayerStats: [], + } as HeroPickrateRawData; + } + + const mapDataIds = mapDataRecords.map((md) => md.id); + + const allPlayerStats = yield* Effect.tryPromise({ + try: () => + prisma.playerStat.findMany({ + where: { MapDataId: { in: mapDataIds } }, + select: { + player_name: true, + player_team: true, + player_hero: true, + hero_time_played: true, + MapDataId: true, + }, + }), + catch: (error) => + new TeamQueryError({ + operation: "fetch player stats for raw hero pickrate data", + cause: error, + }), + }); + + wideEvent.outcome = "success"; + wideEvent.map_count = mapDataRecords.length; + wideEvent.stat_count = allPlayerStats.length; + yield* Metric.increment(analyticsQuerySuccessTotal); + + return { + teamRoster, + mapDataRecords, + allPlayerStats, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(analyticsQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.analytics.getHeroPickrateRawData") + : Effect.logInfo("team.analytics.getHeroPickrateRawData"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen(analyticsQueryDuration(Effect.succeed(durationMs))) + ); + }) + ), + Effect.withSpan("team.analytics.getHeroPickrateRawData") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + function pickrateCacheKeyOf( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ) { + return `${teamId}:${dateFrom?.toISOString() ?? ""}:${dateTo?.toISOString() ?? ""}`; + } + + const pickrateCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const parts = key.split(":"); + const teamIdStr = parts[0]; + const dateFromStr = parts.slice(1, -1).join(":") || ""; + const dateToStr = parts[parts.length - 1] || ""; + const dateFrom = dateFromStr ? new Date(dateFromStr) : undefined; + const dateTo = dateToStr ? new Date(dateToStr) : undefined; + return getHeroPickrateMatrix(Number(teamIdStr), dateFrom, dateTo).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const mapPerfCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getPlayerMapPerformanceMatrix(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + const rawDataCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getHeroPickrateRawData(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getHeroPickrateMatrix: ( + teamId: number, + dateFrom?: Date, + dateTo?: Date + ) => + pickrateCache + .get(pickrateCacheKeyOf(teamId, dateFrom, dateTo)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getPlayerMapPerformanceMatrix: ( + teamId: number, + dateRange?: TeamDateRange + ) => + mapPerfCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + getHeroPickrateRawData: (teamId: number, dateRange?: TeamDateRange) => + rawDataCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamAnalyticsServiceInterface; +}); + +export const TeamAnalyticsServiceLive = Layer.effect( + TeamAnalyticsService, + make +).pipe(Layer.provide(TeamSharedDataServiceLive)); diff --git a/src/data/team/prediction-service.ts b/src/data/team/prediction-service.ts new file mode 100644 index 000000000..ca744260e --- /dev/null +++ b/src/data/team/prediction-service.ts @@ -0,0 +1,292 @@ +import type { HeroName } from "@/types/heroes"; +import { roleHeroMapping } from "@/types/heroes"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@prisma/client"; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { + TeamBanImpactService, + TeamBanImpactServiceLive, +} from "./ban-impact-service"; +import type { TeamQueryError } from "./errors"; +import { + TeamHeroPoolService, + TeamHeroPoolServiceLive, +} from "./hero-pool-service"; +import { + TeamMapModeService, + TeamMapModeServiceLive, +} from "./map-mode-service"; +import { + TeamMatchupService, + TeamMatchupServiceLive, +} from "./matchup-service"; +import { teamCacheMissTotal, teamCacheRequestTotal } from "./metrics"; +import { + TeamRoleStatsService, + TeamRoleStatsServiceLive, +} from "./role-stats-service"; +import type { TeamDateRange } from "./shared-core"; +import { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; + +const predictionQuerySuccessTotal = Metric.counter( + "team.prediction.query.success", + { + description: "Total successful team prediction queries", + incremental: true, + } +); + +const predictionQueryErrorTotal = Metric.counter( + "team.prediction.query.error", + { + description: "Total team prediction query failures", + incremental: true, + } +); + +const predictionQueryDuration = Metric.histogram( + "team.prediction.query.duration_ms", + MetricBoundaries.exponential({ start: 10, factor: 2, count: 10 }), + "Distribution of team prediction query duration in milliseconds" +); + +export type { SimulatorContext } from "./types"; +import type { SimulatorContext } from "./types"; + +const EXCLUDED_MAP_TYPES = new Set<$Enums.MapType>([ + $Enums.MapType.Push, + $Enums.MapType.Clash, +]); + +export type TeamPredictionServiceInterface = { + readonly getSimulatorContext: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; + +export class TeamPredictionService extends Context.Tag( + "@app/data/team/TeamPredictionService" +)() {} + +export const make = Effect.gen(function* () { + const statsService = yield* TeamStatsService; + const banImpactService = yield* TeamBanImpactService; + const heroPoolService = yield* TeamHeroPoolService; + const mapModeService = yield* TeamMapModeService; + const roleStatsService = yield* TeamRoleStatsService; + const matchupService = yield* TeamMatchupService; + + function getSimulatorContext( + teamId: number, + dateRange?: TeamDateRange + ): Effect.Effect { + const startTime = Date.now(); + const wideEvent: Record = { + teamId, + hasDateRange: !!dateRange, + }; + + return Effect.gen(function* () { + yield* Effect.annotateCurrentSpan("teamId", teamId); + + const { + winrates, + banAnalysis, + heroPool, + mapModePerf, + roleTrios, + enemyHeroes, + } = yield* Effect.all( + { + winrates: statsService.getTeamWinrates(teamId, dateRange), + banAnalysis: banImpactService.getTeamBanImpactAnalysis( + teamId, + dateRange + ), + heroPool: heroPoolService.getHeroPoolAnalysis( + teamId, + dateRange?.from, + dateRange?.to + ), + mapModePerf: mapModeService.getMapModePerformance(teamId, dateRange), + roleTrios: roleStatsService.getBestRoleTrios(teamId, dateRange), + enemyHeroes: matchupService.getEnemyHeroAnalysis(teamId, dateRange), + }, + { concurrency: "unbounded" } + ); + + const totalGames = winrates.overallWins + winrates.overallLosses; + const baseWinrate = + totalGames > 0 ? winrates.overallWins / totalGames : 0.5; + + const heroBanDeltas: Record = {}; + const heroBanSampleSizes: Record = {}; + for (const impact of banAnalysis.received.banImpacts) { + heroBanDeltas[impact.hero] = impact.winRateDelta; + heroBanSampleSizes[impact.hero] = impact.mapsBanned; + } + + const ourBanDeltas: Record = {}; + const ourBanSampleSizes: Record = {}; + for (const impact of banAnalysis.outgoing.ourBanImpacts) { + ourBanDeltas[impact.hero] = impact.winRateDelta; + ourBanSampleSizes[impact.hero] = impact.mapsBanned; + } + + const mapWinrates: Record = {}; + const mapSampleSizes: Record = {}; + const availableMaps: string[] = []; + + for (const [mapName, mapData] of Object.entries(winrates.byMap)) { + const mapType = + mapNameToMapTypeMapping[ + mapName as keyof typeof mapNameToMapTypeMapping + ]; + if (mapType && EXCLUDED_MAP_TYPES.has(mapType)) continue; + + const games = mapData.totalWins + mapData.totalLosses; + if (games === 0) continue; + + mapWinrates[mapName] = mapData.totalWinrate / 100; + mapSampleSizes[mapName] = games; + availableMaps.push(mapName); + } + + availableMaps.sort(); + + const mapModeWinrates: Record = {}; + for (const [modeKey, modeData] of Object.entries(mapModePerf.byMode)) { + if (EXCLUDED_MAP_TYPES.has(modeKey as $Enums.MapType)) continue; + if (modeData.gamesPlayed > 0) { + mapModeWinrates[modeKey] = modeData.winrate / 100; + } + } + + const heroPoolWinrates: Record = {}; + const heroPoolSampleSizes: Record = {}; + for (const hero of heroPool.topHeroWinrates) { + heroPoolWinrates[hero.heroName] = hero.winrate / 100; + heroPoolSampleSizes[hero.heroName] = hero.gamesPlayed; + } + + const enemyHeroWinrates: Record = {}; + const enemyHeroSampleSizes: Record = {}; + for (const hero of enemyHeroes.winrateVsHero) { + enemyHeroWinrates[hero.heroName] = hero.winrate / 100; + enemyHeroSampleSizes[hero.heroName] = hero.gamesPlayed; + } + + const allHeroes: HeroName[] = [ + ...roleHeroMapping.Tank, + ...roleHeroMapping.Damage, + ...roleHeroMapping.Support, + ]; + + wideEvent.outcome = "success"; + wideEvent.total_games = totalGames; + wideEvent.base_winrate = baseWinrate; + wideEvent.available_maps = availableMaps.length; + yield* Metric.increment(predictionQuerySuccessTotal); + + return { + baseWinrate, + totalGames, + heroBanDeltas, + heroBanSampleSizes, + ourBanDeltas, + ourBanSampleSizes, + mapWinrates, + mapSampleSizes, + mapModeWinrates, + roleTrioWinrates: roleTrios, + heroPoolWinrates, + heroPoolSampleSizes, + enemyHeroWinrates, + enemyHeroSampleSizes, + availableHeroes: allHeroes, + availableMaps, + }; + }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + wideEvent.outcome = "error"; + wideEvent.error_tag = error._tag; + wideEvent.error_message = error.message; + }).pipe(Effect.andThen(Metric.increment(predictionQueryErrorTotal))) + ), + Effect.ensuring( + Effect.suspend(() => { + const durationMs = Date.now() - startTime; + wideEvent.duration_ms = durationMs; + wideEvent.outcome ??= "interrupted"; + const log = + wideEvent.outcome === "error" + ? Effect.logError("team.prediction.getSimulatorContext") + : Effect.logInfo("team.prediction.getSimulatorContext"); + return log.pipe( + Effect.annotateLogs(wideEvent), + Effect.andThen( + predictionQueryDuration(Effect.succeed(durationMs)) + ) + ); + }) + ), + Effect.withSpan("team.prediction.getSimulatorContext") + ); + } + + const CACHE_TTL = Duration.seconds(30); + const CACHE_CAPACITY = 64; + + function cacheKeyOf(teamId: number, dateRange?: TeamDateRange) { + return `${teamId}:${JSON.stringify(dateRange ?? {})}`; + } + + const simulatorCache = yield* Cache.make({ + capacity: CACHE_CAPACITY, + timeToLive: CACHE_TTL, + lookup: (key: string) => { + const [teamIdStr, rest] = [ + key.slice(0, key.indexOf(":")), + key.slice(key.indexOf(":") + 1), + ]; + const dateRange = JSON.parse(rest) as TeamDateRange | undefined; + const dr = dateRange?.from ? dateRange : undefined; + return getSimulatorContext(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); + + return { + getSimulatorContext: (teamId: number, dateRange?: TeamDateRange) => + simulatorCache + .get(cacheKeyOf(teamId, dateRange)) + .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), + } satisfies TeamPredictionServiceInterface; +}); + +export const TeamPredictionServiceLive = Layer.effect( + TeamPredictionService, + make +).pipe( + Layer.provide( + Layer.mergeAll( + TeamStatsServiceLive, + TeamBanImpactServiceLive, + TeamHeroPoolServiceLive, + TeamMapModeServiceLive, + TeamRoleStatsServiceLive, + TeamMatchupServiceLive + ) + ) +); From 9b1d69fdd99d443976cb52c9d83d4b4ce47f0078 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:22:01 -0400 Subject: [PATCH 23/42] Extract client-needed types from service files to types.ts Move 50+ types from team, scrim, and map service files to their respective types.ts for client-safe imports. Service files re-import from types.ts. Add tempo and killfeed pure functions to client-safe type/helper files. Add server-only to runtime.ts. --- src/data/map/killfeed/service.ts | 81 +++--------------------------- src/data/map/killfeed/types.ts | 77 ++++++++++++++++++++++++++++ src/data/map/tempo-service.ts | 86 ++++++-------------------------- src/data/map/types.ts | 75 ++++++++++++++++++++++++++++ src/data/team/index.ts | 21 ++++++++ src/data/team/types.ts | 72 ++++++++++++++++++++++++++ 6 files changed, 267 insertions(+), 145 deletions(-) diff --git a/src/data/map/killfeed/service.ts b/src/data/map/killfeed/service.ts index ead19732e..8b7526ec7 100644 --- a/src/data/map/killfeed/service.ts +++ b/src/data/map/killfeed/service.ts @@ -18,6 +18,12 @@ import type { KillfeedEvent, UltimateSpan, } from "./types"; +export { + hasAnyUltFeature, + getEventTime, + mergeKillfeedEvents, + isKillDuringUlt, +} from "./types"; const INSTANT_ULT_THRESHOLD = 1.0; const NO_END_ULT_WINDOW = 5.0; @@ -55,81 +61,6 @@ function assignSpanToFight(span: UltimateSpan, fights: Fight[]): number | null { return null; } -export function hasAnyUltFeature(options: KillfeedDisplayOptions): boolean { - return ( - options.showUltBrackets || - options.showUltStartEvents || - options.showUltEndEvents || - options.showUltKillHighlights - ); -} - -export function getEventTime(event: KillfeedEvent): number { - if (event.type === "kill") return event.data.match_time; - if (event.type === "ult_start") return event.data.startTime; - if (event.type === "ult_instant") return event.data.startTime; - return event.data.endTime; -} - -export function mergeKillfeedEvents( - kills: Kill[], - spans: UltimateSpan[], - options: KillfeedDisplayOptions -): KillfeedEvent[] { - const events: KillfeedEvent[] = kills.map((k) => ({ - type: "kill" as const, - data: k, - })); - - const showStartRows = options.showUltStartEvents; - const showEndRows = options.showUltEndEvents; - - if (showStartRows || showEndRows) { - for (const span of spans) { - if (span.isInstant) { - if (showStartRows || showEndRows) { - events.push({ type: "ult_instant", data: span }); - } - } else { - if (showStartRows) { - events.push({ type: "ult_start", data: span }); - } - if (showEndRows) { - events.push({ type: "ult_end", data: span }); - } - } - } - } - - events.sort((a, b) => { - const timeA = getEventTime(a); - const timeB = getEventTime(b); - - if (timeA !== timeB) return timeA - timeB; - - const priority = { ult_start: 0, kill: 1, ult_end: 2, ult_instant: 0 }; - return priority[a.type] - priority[b.type]; - }); - - return events; -} - -export function isKillDuringUlt( - kill: Kill, - spans: UltimateSpan[] -): UltimateSpan | null { - for (const span of spans) { - if ( - kill.match_time >= span.startTime && - kill.match_time <= span.endTime && - kill.attacker_team === span.playerTeam - ) { - return span; - } - } - return null; -} - export type KillfeedServiceInterface = { readonly getUltimateSpans: ( mapId: number diff --git a/src/data/map/killfeed/types.ts b/src/data/map/killfeed/types.ts index 36691a827..7e5ddbeba 100644 --- a/src/data/map/killfeed/types.ts +++ b/src/data/map/killfeed/types.ts @@ -62,3 +62,80 @@ export type SerializedCalibrationData = { mapType: string; roundStarts: { match_time: number; objective_index: number }[]; } | null; + +// ── Pure helpers (safe for client components) ──────────────────────── + +export function hasAnyUltFeature(options: KillfeedDisplayOptions): boolean { + return ( + options.showUltBrackets || + options.showUltStartEvents || + options.showUltEndEvents || + options.showUltKillHighlights + ); +} + +export function getEventTime(event: KillfeedEvent): number { + if (event.type === "kill") return event.data.match_time; + if (event.type === "ult_start") return event.data.startTime; + if (event.type === "ult_instant") return event.data.startTime; + return event.data.endTime; +} + +export function mergeKillfeedEvents( + kills: Kill[], + spans: UltimateSpan[], + options: KillfeedDisplayOptions +): KillfeedEvent[] { + const events: KillfeedEvent[] = kills.map((k) => ({ + type: "kill" as const, + data: k, + })); + + const showStartRows = options.showUltStartEvents; + const showEndRows = options.showUltEndEvents; + + if (showStartRows || showEndRows) { + for (const span of spans) { + if (span.isInstant) { + if (showStartRows || showEndRows) { + events.push({ type: "ult_instant", data: span }); + } + } else { + if (showStartRows) { + events.push({ type: "ult_start", data: span }); + } + if (showEndRows) { + events.push({ type: "ult_end", data: span }); + } + } + } + } + + events.sort((a, b) => { + const timeA = getEventTime(a); + const timeB = getEventTime(b); + + if (timeA !== timeB) return timeA - timeB; + + const priority = { ult_start: 0, kill: 1, ult_end: 2, ult_instant: 0 }; + return priority[a.type] - priority[b.type]; + }); + + return events; +} + +export function isKillDuringUlt( + kill: Kill, + spans: UltimateSpan[] +): UltimateSpan | null { + for (const span of spans) { + if ( + kill.match_time >= span.startTime && + kill.match_time <= span.endTime && + kill.attacker_team === span.playerTeam + ) { + return span; + } + } + return null; +} diff --git a/src/data/map/tempo-service.ts b/src/data/map/tempo-service.ts index 9b60654e5..e23aeaa6b 100644 --- a/src/data/map/tempo-service.ts +++ b/src/data/map/tempo-service.ts @@ -16,46 +16,22 @@ import { tempoQuerySuccessTotal, } from "./metrics"; -export type TempoDataPoint = { - time: number; - team1: number; - team2: number; -}; - -export type UltPin = { - time: number; - hero: string; - playerName: string; - team: "team1" | "team2"; -}; - -export type FightBoundary = { - start: number; - end: number; - fightNumber: number; -}; - -export type KillPin = { - time: number; - hero: string; - playerName: string; - victimHero: string; - victimName: string; - team: "team1" | "team2"; -}; - -export type TempoChartData = { - combinedSeries: TempoDataPoint[]; - killsSeries: TempoDataPoint[]; - ultsSeries: TempoDataPoint[]; - ultPins: UltPin[]; - killPins: KillPin[]; - fightBoundaries: FightBoundary[]; - matchStartTime: number; - matchEndTime: number; - team1Name: string; - team2Name: string; -}; +export type { + TempoDataPoint, + UltPin, + FightBoundary, + KillPin, + TempoChartData, +} from "./types"; +export { tempoPointsToSvgPath } from "./types"; + +import type { + TempoDataPoint, + FightBoundary, + KillPin, + UltPin, + TempoChartData, +} from "./types"; type WeightedEvent = { time: number; weight: number }; @@ -155,36 +131,6 @@ export function fightsToBoundaries(fights: Fight[]): FightBoundary[] { })); } -export function tempoPointsToSvgPath( - points: { x: number; y: number }[] -): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M${points[0].x},${points[0].y}`; - if (points.length === 2) { - return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`; - } - - const alpha = 0.5; - - let d = `M${points[0].x},${points[0].y}`; - - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[Math.max(0, i - 1)]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[Math.min(points.length - 1, i + 2)]; - - const cp1x = p1.x + (p2.x - p0.x) / (6 / alpha); - const cp1y = p1.y + (p2.y - p0.y) / (6 / alpha); - const cp2x = p2.x - (p3.x - p1.x) / (6 / alpha); - const cp2y = p2.y - (p3.y - p1.y) / (6 / alpha); - - d += `C${cp1x},${cp1y},${cp2x},${cp2y},${p2.x},${p2.y}`; - } - - return d; -} - export type TempoServiceInterface = { readonly getTempoChartData: ( mapId: number diff --git a/src/data/map/types.ts b/src/data/map/types.ts index 8783a4b10..8b839eee2 100644 --- a/src/data/map/types.ts +++ b/src/data/map/types.ts @@ -1,5 +1,80 @@ import { Schema as S } from "effect"; +// ── Tempo types & pure helpers (safe for client components) ────────── + +export type TempoDataPoint = { + time: number; + team1: number; + team2: number; +}; + +export type UltPin = { + time: number; + hero: string; + playerName: string; + team: "team1" | "team2"; +}; + +export type FightBoundary = { + start: number; + end: number; + fightNumber: number; +}; + +export type KillPin = { + time: number; + hero: string; + playerName: string; + victimHero: string; + victimName: string; + team: "team1" | "team2"; +}; + +export type TempoChartData = { + combinedSeries: TempoDataPoint[]; + killsSeries: TempoDataPoint[]; + ultsSeries: TempoDataPoint[]; + ultPins: UltPin[]; + killPins: KillPin[]; + fightBoundaries: FightBoundary[]; + matchStartTime: number; + matchEndTime: number; + team1Name: string; + team2Name: string; +}; + +export function tempoPointsToSvgPath( + points: { x: number; y: number }[] +): string { + if (points.length === 0) return ""; + if (points.length === 1) return `M${points[0].x},${points[0].y}`; + if (points.length === 2) { + return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`; + } + + const alpha = 0.5; + + let d = `M${points[0].x},${points[0].y}`; + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(points.length - 1, i + 2)]; + + const cp1x = p1.x + (p2.x - p0.x) / (6 / alpha); + const cp1y = p1.y + (p2.y - p0.y) / (6 / alpha); + const cp2x = p2.x - (p3.x - p1.x) / (6 / alpha); + const cp2y = p2.y - (p3.y - p1.y) / (6 / alpha); + + d += `C${cp1x},${cp1y},${cp2x},${cp2y},${p2.x},${p2.y}`; + } + + return d; +} + +// ── Schema definitions ─────────────────────────────────────────────── + export const MapDataIdSchema = S.Number.pipe( S.int(), S.positive({ message: () => "MapData ID must be a positive integer" }) diff --git a/src/data/team/index.ts b/src/data/team/index.ts index e0a998e34..5f4e5e27d 100644 --- a/src/data/team/index.ts +++ b/src/data/team/index.ts @@ -88,6 +88,15 @@ export type { MatchupWinrateData, EnemyHeroWinrate, EnemyHeroAnalysis, + // analytics types + HeroPickrate, + PlayerHeroData, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerMapPerformance, + PlayerMapPerformanceMatrix, + // prediction types + SimulatorContext, } from "./types"; export { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; @@ -152,3 +161,15 @@ export type { TeamComparisonServiceInterface } from "./comparison-service"; export { TeamMatchupService, TeamMatchupServiceLive } from "./matchup-service"; export type { TeamMatchupServiceInterface } from "./matchup-service"; + +export { + TeamAnalyticsService, + TeamAnalyticsServiceLive, +} from "./analytics-service"; +export type { TeamAnalyticsServiceInterface } from "./analytics-service"; + +export { + TeamPredictionService, + TeamPredictionServiceLive, +} from "./prediction-service"; +export type { TeamPredictionServiceInterface } from "./prediction-service"; diff --git a/src/data/team/types.ts b/src/data/team/types.ts index 2c22b3c34..1fb2f3d02 100644 --- a/src/data/team/types.ts +++ b/src/data/team/types.ts @@ -541,3 +541,75 @@ export type EnemyHeroWinrate = { export type EnemyHeroAnalysis = { winrateVsHero: EnemyHeroWinrate[]; }; + +// ---------- analytics-service types ---------- + +export type HeroPickrate = { + heroName: HeroName; + role: "Tank" | "Damage" | "Support"; + playtime: number; + gamesPlayed: number; +}; + +export type PlayerHeroData = { + playerName: string; + heroes: HeroPickrate[]; + totalPlaytime: number; +}; + +export type HeroPickrateMatrix = { + players: PlayerHeroData[]; + allHeroes: HeroName[]; +}; + +export type HeroPickrateRawData = { + teamRoster: string[]; + mapDataRecords: { + id: number; + name: string | null; + scrimDate: Date; + }[]; + allPlayerStats: { + player_name: string; + player_team: string; + player_hero: string; + hero_time_played: number; + MapDataId: number | null; + }[]; +}; + +export type PlayerMapPerformance = { + playerName: string; + mapName: string; + wins: number; + losses: number; + winrate: number; + gamesPlayed: number; +}; + +export type PlayerMapPerformanceMatrix = { + players: string[]; + maps: string[]; + performance: PlayerMapPerformance[]; +}; + +// ---------- prediction-service types ---------- + +export type SimulatorContext = { + baseWinrate: number; + totalGames: number; + heroBanDeltas: Record; + heroBanSampleSizes: Record; + ourBanDeltas: Record; + ourBanSampleSizes: Record; + mapWinrates: Record; + mapSampleSizes: Record; + mapModeWinrates: Record; + roleTrioWinrates: RoleTrio[]; + heroPoolWinrates: Record; + heroPoolSampleSizes: Record; + enemyHeroWinrates: Record; + enemyHeroSampleSizes: Record; + availableHeroes: HeroName[]; + availableMaps: string[]; +}; From 25ac1bb91e28439607c92f365a7ba0656b588cde Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:22:15 -0400 Subject: [PATCH 24/42] Migrate client component type imports to new type paths Switch 73 client components from old DTO type imports to client-safe @/data/{domain}/types paths. No service imports in client components. Pure functions (killfeed helpers, tempo utils) imported from client-safe helper files. --- .../data-labeling/match-labeling-view.tsx | 2 +- .../data-labeling/player-assignment-panel.tsx | 2 +- .../data-labeling/roster-role-utils.ts | 2 +- .../data-labeling/unlabeled-match-list.tsx | 2 +- src/components/map/analysis/analysis-card.tsx | 2 +- .../map/analysis/analysis-sections.tsx | 2 +- src/components/map/default-overview.tsx | 24 ++++++++++++++----- src/components/map/fight-timeline.tsx | 15 ++++++------ src/components/map/heatmap/heatmap-canvas.tsx | 2 +- .../map/heatmap/heatmap-control-tabs.tsx | 2 +- src/components/map/heatmap/heatmap-tab.tsx | 10 ++++++-- src/components/map/import-to-canvas-link.tsx | 2 +- src/components/map/killfeed-controls.tsx | 2 +- src/components/map/killfeed-table.tsx | 12 ++++------ src/components/map/killfeed-with-timeline.tsx | 4 ++-- src/components/map/killfeed.tsx | 22 +++++++++++++---- .../map/replay/replay-event-feed.tsx | 2 +- src/components/map/replay/replay-tab.tsx | 10 ++++++-- src/components/map/replay/replay-timeline.tsx | 2 +- src/components/map/replay/replay-viewer.tsx | 2 +- .../map/tempo/tempo-chart-server.tsx | 10 ++++++-- src/components/map/tempo/tempo-chart.tsx | 2 +- src/components/map/tempo/tempo-curve.tsx | 2 +- src/components/map/tempo/tempo-scrubber.tsx | 7 ++++-- src/components/map/ult-gutter.tsx | 2 +- .../positional/use-kill-calibration.ts | 2 +- src/components/scouting/ban-strategy.tsx | 2 +- src/components/scouting/map-veto-advisor.tsx | 2 +- src/components/scouting/player-hero-pool.tsx | 2 +- .../scouting/player-hero-zscores.tsx | 2 +- .../scouting/player-kill-analysis.tsx | 2 +- .../scouting/player-map-winrates.tsx | 2 +- src/components/scouting/player-matchups.tsx | 2 +- .../scouting/player-performance-radar.tsx | 2 +- .../scouting/player-profile-header.tsx | 2 +- .../scouting/player-scrim-overview.tsx | 2 +- src/components/scouting/player-search.tsx | 2 +- .../scouting/player-strengths-weaknesses.tsx | 2 +- .../scouting/player-tournament-history.tsx | 2 +- src/components/scrim/swap-win-rate-chart.tsx | 2 +- .../team/ability-impact-analysis-card.tsx | 2 +- .../stats/team/best-role-trios-card.tsx | 2 +- .../stats/team/hero-ban-impact-card.tsx | 2 +- .../stats/team/hero-our-bans-card.tsx | 2 +- .../stats/team/hero-pool-container.tsx | 2 +- .../stats/team/hero-pool-overview-card.tsx | 2 +- .../stats/team/hero-winrates-card.tsx | 2 +- .../stats/team/map-mode-performance-card.tsx | 2 +- .../stats/team/matchup-winrate-tab.tsx | 2 +- .../stats/team/quick-stats-card.tsx | 2 +- .../stats/team/recent-form-card.tsx | 2 +- .../stats/team/role-balance-radar.tsx | 2 +- .../stats/team/role-performance-card.tsx | 2 +- .../stats/team/swap-overview-card.tsx | 2 +- src/components/stats/team/swap-pairs-card.tsx | 2 +- .../stats/team/swap-player-breakdown-card.tsx | 2 +- .../stats/team/swap-timing-card.tsx | 2 +- .../stats/team/swap-winrate-impact-card.tsx | 2 +- .../stats/team/team-fight-stats-card.tsx | 2 +- .../stats/team/ult-impact-analysis-card.tsx | 2 +- .../stats/team/ult-player-rankings-card.tsx | 2 +- .../stats/team/ult-role-breakdown-card.tsx | 2 +- .../stats/team/ult-usage-overview-card.tsx | 2 +- .../stats/team/ultimate-economy-card.tsx | 2 +- .../stats/team/win-loss-streaks-card.tsx | 2 +- .../stats/team/win-probability-insights.tsx | 2 +- .../stats/team/winrate-over-time-chart.tsx | 2 +- .../targets/player-target-detail.tsx | 2 +- src/components/targets/player-targets-tab.tsx | 2 +- src/components/targets/stat-trend-chart.tsx | 2 +- src/components/targets/target-narrative.tsx | 2 +- .../targets/target-progress-card.tsx | 2 +- .../targets/team-targets-overview.tsx | 2 +- 73 files changed, 143 insertions(+), 99 deletions(-) diff --git a/src/components/data-labeling/match-labeling-view.tsx b/src/components/data-labeling/match-labeling-view.tsx index 89482c2db..94092dc78 100644 --- a/src/components/data-labeling/match-labeling-view.tsx +++ b/src/components/data-labeling/match-labeling-view.tsx @@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { MatchForLabeling, MatchMapForLabeling, -} from "@/data/data-labeling-dto"; +} from "@/data/admin/types"; import { toHero } from "@/lib/utils"; import { heroRoleMapping, type HeroName } from "@/types/heroes"; import { YouTubeEmbed } from "@next/third-parties/google"; diff --git a/src/components/data-labeling/player-assignment-panel.tsx b/src/components/data-labeling/player-assignment-panel.tsx index 650dc256e..1cf9ef489 100644 --- a/src/components/data-labeling/player-assignment-panel.tsx +++ b/src/components/data-labeling/player-assignment-panel.tsx @@ -8,7 +8,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { RosterPlayerForLabeling } from "@/data/data-labeling-dto"; +import type { RosterPlayerForLabeling } from "@/data/admin/types"; import { toHero } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/data-labeling/roster-role-utils.ts b/src/components/data-labeling/roster-role-utils.ts index fab135f5e..87474d09e 100644 --- a/src/components/data-labeling/roster-role-utils.ts +++ b/src/components/data-labeling/roster-role-utils.ts @@ -1,5 +1,5 @@ import { heroRoleMapping, type HeroName } from "@/types/heroes"; -import type { RosterPlayerForLabeling } from "@/data/data-labeling-dto"; +import type { RosterPlayerForLabeling } from "@/data/admin/types"; import type { RosterRole } from "@prisma/client"; export const HERO_ROLE_TO_ROSTER_ROLES: Record = { diff --git a/src/components/data-labeling/unlabeled-match-list.tsx b/src/components/data-labeling/unlabeled-match-list.tsx index 94e1238e0..73a266bee 100644 --- a/src/components/data-labeling/unlabeled-match-list.tsx +++ b/src/components/data-labeling/unlabeled-match-list.tsx @@ -18,7 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { UnlabeledMatchesResult } from "@/data/data-labeling-dto"; +import type { UnlabeledMatchesResult } from "@/data/admin/types"; import { useQuery } from "@tanstack/react-query"; import type { Route } from "next"; import { useTranslations } from "next-intl"; diff --git a/src/components/map/analysis/analysis-card.tsx b/src/components/map/analysis/analysis-card.tsx index 62c7cf56f..1f55e4df1 100644 --- a/src/components/map/analysis/analysis-card.tsx +++ b/src/components/map/analysis/analysis-card.tsx @@ -14,7 +14,7 @@ import { type TimingData, type UltimatesData, } from "@/components/map/analysis/analysis-sections"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; +import type { SerializedCalibrationData } from "@/data/map/killfeed/types"; import { Card, CardContent, diff --git a/src/components/map/analysis/analysis-sections.tsx b/src/components/map/analysis/analysis-sections.tsx index 69ced48a6..4bf2ff3fc 100644 --- a/src/components/map/analysis/analysis-sections.tsx +++ b/src/components/map/analysis/analysis-sections.tsx @@ -17,7 +17,7 @@ import { HoverCardContent, HoverCardTrigger, } from "@/components/ui/hover-card"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; +import type { SerializedCalibrationData } from "@/data/map/killfeed/types"; import type { PlayerUltComparison, SubroleUltTiming, diff --git a/src/components/map/default-overview.tsx b/src/components/map/default-overview.tsx index 9600cbd44..4e7ce0b8d 100644 --- a/src/components/map/default-overview.tsx +++ b/src/components/map/default-overview.tsx @@ -1,10 +1,10 @@ import { AnalysisCardAccordion as AnalysisCard } from "@/components/map/analysis/analysis-card-accordion"; import { OverviewTable } from "@/components/map/overview-table"; import { - getKillfeedCalibration, + KillfeedCalibrationService, serializeCalibrationData, -} from "@/data/killfeed-calibration-dto"; -import { getRotationDeathAnalysis } from "@/data/rotation-death-dto"; + RotationDeathService, +} from "@/data/map"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { @@ -23,7 +23,7 @@ import { } from "@/components/ui/card"; import { CardIcon } from "@/components/ui/card-icon"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { filterUtilityRoundStartSwaps } from "@/data/team-hero-swap-dto"; +import { filterUtilityRoundStartSwaps } from "@/data/team/hero-swap-service"; import { getAjaxes } from "@/lib/analytics"; import { calculateMVPScoresForMap } from "@/lib/mvp-score"; import { resolveMapDataId } from "@/lib/map-data-resolver"; @@ -264,8 +264,20 @@ export async function DefaultOverview({ ) ) ), - positionalEnabled ? getRotationDeathAnalysis(id) : null, - positionalEnabled ? getKillfeedCalibration(id) : null, + positionalEnabled + ? AppRuntime.runPromise( + RotationDeathService.pipe( + Effect.flatMap((svc) => svc.getRotationDeathAnalysis(id)) + ) + ) + : null, + positionalEnabled + ? AppRuntime.runPromise( + KillfeedCalibrationService.pipe( + Effect.flatMap((svc) => svc.getKillfeedCalibration(id)) + ) + ) + : null, ]); const team1Ults = ultimateStarts.filter((u) => u.player_team === team1Name); diff --git a/src/components/map/fight-timeline.tsx b/src/components/map/fight-timeline.tsx index b4afba4ac..0cd93c64c 100644 --- a/src/components/map/fight-timeline.tsx +++ b/src/components/map/fight-timeline.tsx @@ -13,13 +13,14 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; -import type { - KillfeedDisplayOptions, - KillfeedEvent, - UltimateSpan, -} from "@/data/killfeed-dto"; -import { getEventTime, isKillDuringUlt } from "@/data/killfeed-dto"; +import { + getEventTime, + isKillDuringUlt, + type KillfeedDisplayOptions, + type KillfeedEvent, + type SerializedCalibrationData, + type UltimateSpan, +} from "@/data/map/killfeed/types"; import { useGoToReplay } from "@/components/map/map-tabs"; import { cn, toHero, toKebabCase, toTimestamp } from "@/lib/utils"; import type { Kill, RoundEnd } from "@prisma/client"; diff --git a/src/components/map/heatmap/heatmap-canvas.tsx b/src/components/map/heatmap/heatmap-canvas.tsx index 16055d9b3..d09da91d3 100644 --- a/src/components/map/heatmap/heatmap-canvas.tsx +++ b/src/components/map/heatmap/heatmap-canvas.tsx @@ -1,6 +1,6 @@ "use client"; -import type { HeatmapSubMap, KillPoint } from "@/data/heatmap-dto"; +import type { HeatmapSubMap, KillPoint } from "@/data/map/heatmap/types"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; import { toHero, toTimestamp } from "@/lib/utils"; import Image from "next/image"; diff --git a/src/components/map/heatmap/heatmap-control-tabs.tsx b/src/components/map/heatmap/heatmap-control-tabs.tsx index 6eba5c0c5..85b8f558c 100644 --- a/src/components/map/heatmap/heatmap-control-tabs.tsx +++ b/src/components/map/heatmap/heatmap-control-tabs.tsx @@ -1,6 +1,6 @@ "use client"; -import type { HeatmapSubMap } from "@/data/heatmap-dto"; +import type { HeatmapSubMap } from "@/data/map/heatmap/types"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { HeatmapCanvas } from "./heatmap-canvas"; diff --git a/src/components/map/heatmap/heatmap-tab.tsx b/src/components/map/heatmap/heatmap-tab.tsx index f8e8bbc89..d94ebf601 100644 --- a/src/components/map/heatmap/heatmap-tab.tsx +++ b/src/components/map/heatmap/heatmap-tab.tsx @@ -1,11 +1,17 @@ -import { getHeatmapData } from "@/data/heatmap-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { HeatmapService } from "@/data/map"; import { getTranslations } from "next-intl/server"; import { HeatmapCanvas } from "./heatmap-canvas"; import { HeatmapControlTabs } from "./heatmap-control-tabs"; export async function HeatmapTab({ id }: { id: number }) { const [data, t] = await Promise.all([ - getHeatmapData(id), + AppRuntime.runPromise( + HeatmapService.pipe( + Effect.flatMap((svc) => svc.getHeatmapData(id)) + ) + ), getTranslations("mapPage.heatmap"), ]); diff --git a/src/components/map/import-to-canvas-link.tsx b/src/components/map/import-to-canvas-link.tsx index 0ba911840..fff3e00ae 100644 --- a/src/components/map/import-to-canvas-link.tsx +++ b/src/components/map/import-to-canvas-link.tsx @@ -1,6 +1,6 @@ "use client"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; +import type { SerializedCalibrationData } from "@/data/map/killfeed/types"; import { getControlSubMapName } from "@/lib/map-calibration/control-map-index"; import type { LoadedCalibration } from "@/lib/map-calibration/load-calibration"; import { worldToImage } from "@/lib/map-calibration/world-to-image"; diff --git a/src/components/map/killfeed-controls.tsx b/src/components/map/killfeed-controls.tsx index 122145fa5..3d71881c1 100644 --- a/src/components/map/killfeed-controls.tsx +++ b/src/components/map/killfeed-controls.tsx @@ -10,7 +10,7 @@ import { } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; -import type { KillfeedDisplayOptions } from "@/data/killfeed-dto"; +import type { KillfeedDisplayOptions } from "@/data/map/killfeed/types"; import { SlidersHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/map/killfeed-table.tsx b/src/components/map/killfeed-table.tsx index 6b58371db..a64188ea1 100644 --- a/src/components/map/killfeed-table.tsx +++ b/src/components/map/killfeed-table.tsx @@ -22,17 +22,15 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; -import type { - KillfeedDisplayOptions, - KillfeedEvent, - UltimateSpan, -} from "@/data/killfeed-dto"; import { hasAnyUltFeature, isKillDuringUlt, mergeKillfeedEvents, -} from "@/data/killfeed-dto"; + type KillfeedDisplayOptions, + type KillfeedEvent, + type SerializedCalibrationData, + type UltimateSpan, +} from "@/data/map/killfeed/types"; import { useGoToReplay } from "@/components/map/map-tabs"; import { cn, toHero, toKebabCase, toTimestamp } from "@/lib/utils"; import type { Kill, RoundEnd } from "@prisma/client"; diff --git a/src/components/map/killfeed-with-timeline.tsx b/src/components/map/killfeed-with-timeline.tsx index a17d19f59..dffc3ddbe 100644 --- a/src/components/map/killfeed-with-timeline.tsx +++ b/src/components/map/killfeed-with-timeline.tsx @@ -10,12 +10,12 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; import { DEFAULT_KILLFEED_OPTIONS, type FightUltimateData, type KillfeedDisplayOptions, -} from "@/data/killfeed-dto"; + type SerializedCalibrationData, +} from "@/data/map/killfeed/types"; import type { Kill, RoundEnd } from "@prisma/client"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; diff --git a/src/components/map/killfeed.tsx b/src/components/map/killfeed.tsx index 51edc90ab..9f0e6952d 100644 --- a/src/components/map/killfeed.tsx +++ b/src/components/map/killfeed.tsx @@ -6,11 +6,13 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import { getUltimateSpans } from "@/data/killfeed-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; import { - getKillfeedCalibration, + KillfeedService, + KillfeedCalibrationService, serializeCalibrationData, -} from "@/data/killfeed-calibration-dto"; +} from "@/data/map"; import { coachingCanvas, positionalData } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; @@ -45,13 +47,23 @@ export async function Killfeed({ }), prisma.matchStart.findFirst({ where: { MapDataId: mapDataId } }), groupKillsIntoFights(id), - getUltimateSpans(id), + AppRuntime.runPromise( + KillfeedService.pipe( + Effect.flatMap((svc) => svc.getUltimateSpans(id)) + ) + ), positionalData(), coachingCanvas(), ]); const calibrationData = positionalEnabled - ? serializeCalibrationData(await getKillfeedCalibration(id)) + ? serializeCalibrationData( + await AppRuntime.runPromise( + KillfeedCalibrationService.pipe( + Effect.flatMap((svc) => svc.getKillfeedCalibration(id)) + ) + ) + ) : null; const roundEnds = removeDuplicateRows(roundEndRows); diff --git a/src/components/map/replay/replay-event-feed.tsx b/src/components/map/replay/replay-event-feed.tsx index fa6a6ce5b..b7ca0b358 100644 --- a/src/components/map/replay/replay-event-feed.tsx +++ b/src/components/map/replay/replay-event-feed.tsx @@ -1,6 +1,6 @@ "use client"; -import type { DisplayEvent } from "@/data/replay-dto"; +import type { DisplayEvent } from "@/data/map/replay/types"; import { toHero, toTimestamp } from "@/lib/utils"; import Image from "next/image"; import { useEffect, useMemo, useRef } from "react"; diff --git a/src/components/map/replay/replay-tab.tsx b/src/components/map/replay/replay-tab.tsx index fb1882e56..bfab0fd63 100644 --- a/src/components/map/replay/replay-tab.tsx +++ b/src/components/map/replay/replay-tab.tsx @@ -1,10 +1,16 @@ -import { getReplayData } from "@/data/replay-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { ReplayService } from "@/data/map"; import { getTranslations } from "next-intl/server"; import { ReplayViewer } from "./replay-viewer"; export async function ReplayTab({ id }: { id: number }) { const [data, t] = await Promise.all([ - getReplayData(id), + AppRuntime.runPromise( + ReplayService.pipe( + Effect.flatMap((svc) => svc.getReplayData(id)) + ) + ), getTranslations("mapPage.replay"), ]); diff --git a/src/components/map/replay/replay-timeline.tsx b/src/components/map/replay/replay-timeline.tsx index 3888681aa..21a178165 100644 --- a/src/components/map/replay/replay-timeline.tsx +++ b/src/components/map/replay/replay-timeline.tsx @@ -4,7 +4,7 @@ import type { DisplayEvent, KillDisplayEvent, RoundDisplayEvent, -} from "@/data/replay-dto"; +} from "@/data/map/replay/types"; import { Slider } from "@/components/ui/slider"; import { PLAYBACK_SPEEDS, type ReplayStore } from "@/stores/replay-store"; import { useSelector } from "@xstate/store/react"; diff --git a/src/components/map/replay/replay-viewer.tsx b/src/components/map/replay/replay-viewer.tsx index 0b3374c2d..ea1165ff4 100644 --- a/src/components/map/replay/replay-viewer.tsx +++ b/src/components/map/replay/replay-viewer.tsx @@ -4,7 +4,7 @@ import type { DisplayEvent, PositionSample, ReplayCalibration, -} from "@/data/replay-dto"; +} from "@/data/map/replay/types"; import type { LoadedCalibration } from "@/lib/map-calibration/load-calibration"; import { getControlSubMapName } from "@/lib/map-calibration/control-map-index"; import { diff --git a/src/components/map/tempo/tempo-chart-server.tsx b/src/components/map/tempo/tempo-chart-server.tsx index 8913656d8..3283f4a13 100644 --- a/src/components/map/tempo/tempo-chart-server.tsx +++ b/src/components/map/tempo/tempo-chart-server.tsx @@ -1,4 +1,6 @@ -import { getTempoChartData } from "@/data/tempo-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TempoService } from "@/data/map"; import { getTranslations } from "next-intl/server"; import { TempoChart } from "./tempo-chart"; @@ -14,7 +16,11 @@ export async function TempoChartServer({ team2Color, }: TempoChartServerProps) { const [data, t] = await Promise.all([ - getTempoChartData(id), + AppRuntime.runPromise( + TempoService.pipe( + Effect.flatMap((svc) => svc.getTempoChartData(id)) + ) + ), getTranslations("mapPage.events.tempo"), ]); diff --git a/src/components/map/tempo/tempo-chart.tsx b/src/components/map/tempo/tempo-chart.tsx index 34fc4e280..71ecbc1f7 100644 --- a/src/components/map/tempo/tempo-chart.tsx +++ b/src/components/map/tempo/tempo-chart.tsx @@ -14,7 +14,7 @@ import type { KillPin, TempoDataPoint, UltPin, -} from "@/data/tempo-dto"; +} from "@/data/map/types"; import { toTimestamp } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/map/tempo/tempo-curve.tsx b/src/components/map/tempo/tempo-curve.tsx index f6e86286f..6aa48606a 100644 --- a/src/components/map/tempo/tempo-curve.tsx +++ b/src/components/map/tempo/tempo-curve.tsx @@ -1,6 +1,6 @@ "use client"; -import { tempoPointsToSvgPath } from "@/data/tempo-dto"; +import { tempoPointsToSvgPath } from "@/data/map/types"; type TempoCurveProps = { points: { x: number; y: number }[]; diff --git a/src/components/map/tempo/tempo-scrubber.tsx b/src/components/map/tempo/tempo-scrubber.tsx index c1d457bfe..8ad3d0788 100644 --- a/src/components/map/tempo/tempo-scrubber.tsx +++ b/src/components/map/tempo/tempo-scrubber.tsx @@ -1,7 +1,10 @@ "use client"; -import type { FightBoundary, TempoDataPoint } from "@/data/tempo-dto"; -import { tempoPointsToSvgPath } from "@/data/tempo-dto"; +import { + tempoPointsToSvgPath, + type FightBoundary, + type TempoDataPoint, +} from "@/data/map/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; diff --git a/src/components/map/ult-gutter.tsx b/src/components/map/ult-gutter.tsx index 74390124c..a2f5dbb16 100644 --- a/src/components/map/ult-gutter.tsx +++ b/src/components/map/ult-gutter.tsx @@ -5,7 +5,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { UltimateSpan } from "@/data/killfeed-dto"; +import type { UltimateSpan } from "@/data/map/killfeed/types"; import { toHero, toTimestamp } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/positional/use-kill-calibration.ts b/src/components/positional/use-kill-calibration.ts index 5bdcabddd..eab7d290e 100644 --- a/src/components/positional/use-kill-calibration.ts +++ b/src/components/positional/use-kill-calibration.ts @@ -1,6 +1,6 @@ import { getControlSubMapName } from "@/lib/map-calibration/control-map-index"; import type { LoadedCalibration } from "@/lib/map-calibration/load-calibration"; -import type { SerializedCalibrationData } from "@/data/killfeed-calibration-dto"; +import type { SerializedCalibrationData } from "@/data/map/killfeed/types"; import { useMemo } from "react"; export function useKillCalibration( diff --git a/src/components/scouting/ban-strategy.tsx b/src/components/scouting/ban-strategy.tsx index 4d413211d..f64d0bbf4 100644 --- a/src/components/scouting/ban-strategy.tsx +++ b/src/components/scouting/ban-strategy.tsx @@ -14,7 +14,7 @@ import type { HeroBanIntelligence, HeroExposure, ProtectedHero, -} from "@/data/hero-ban-intelligence-dto"; +} from "@/data/intelligence/types"; import { cn } from "@/lib/utils"; import { AlertTriangle, diff --git a/src/components/scouting/map-veto-advisor.tsx b/src/components/scouting/map-veto-advisor.tsx index 5d2e81193..16167ff5c 100644 --- a/src/components/scouting/map-veto-advisor.tsx +++ b/src/components/scouting/map-veto-advisor.tsx @@ -12,7 +12,7 @@ import { import type { MapIntelligence, MapMatchupEntry, -} from "@/data/map-intelligence-dto"; +} from "@/data/intelligence/types"; import { cn } from "@/lib/utils"; import { ArrowDown, ArrowRight, ArrowUp, Info } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/player-hero-pool.tsx b/src/components/scouting/player-hero-pool.tsx index 6f28cde37..45068aa0a 100644 --- a/src/components/scouting/player-hero-pool.tsx +++ b/src/components/scouting/player-hero-pool.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { HeroFrequency } from "@/data/player-scouting-dto"; +import type { HeroFrequency } from "@/data/player/types"; import { cn, toHero } from "@/lib/utils"; import { Info } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/player-hero-zscores.tsx b/src/components/scouting/player-hero-zscores.tsx index 3234822df..e98db4131 100644 --- a/src/components/scouting/player-hero-zscores.tsx +++ b/src/components/scouting/player-hero-zscores.tsx @@ -14,7 +14,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { ScoutingHeroPerformance } from "@/data/player-scouting-analytics-dto"; +import type { ScoutingHeroPerformance } from "@/data/player/types"; import { cn, toHero } from "@/lib/utils"; import { ChevronDown } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/player-kill-analysis.tsx b/src/components/scouting/player-kill-analysis.tsx index 665b4cea6..bfb7aa97b 100644 --- a/src/components/scouting/player-kill-analysis.tsx +++ b/src/components/scouting/player-kill-analysis.tsx @@ -17,7 +17,7 @@ import { import type { KillPatterns, RoleDistributionEntry, -} from "@/data/player-scouting-analytics-dto"; +} from "@/data/player/types"; import { toHero } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/scouting/player-map-winrates.tsx b/src/components/scouting/player-map-winrates.tsx index db146379e..542516968 100644 --- a/src/components/scouting/player-map-winrates.tsx +++ b/src/components/scouting/player-map-winrates.tsx @@ -15,7 +15,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { CompetitiveMapWinrates } from "@/data/player-scouting-analytics-dto"; +import type { CompetitiveMapWinrates } from "@/data/player/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/scouting/player-matchups.tsx b/src/components/scouting/player-matchups.tsx index 4119fc2b4..eed2a9238 100644 --- a/src/components/scouting/player-matchups.tsx +++ b/src/components/scouting/player-matchups.tsx @@ -13,7 +13,7 @@ import type { PlayerHeroDepth, PlayerIntelligence, PlayerVulnerability, -} from "@/data/player-intelligence-dto"; +} from "@/data/player/types"; import { cn } from "@/lib/utils"; import { AlertTriangle, CheckCircle2, Info, Shield, User } from "lucide-react"; import { useMemo } from "react"; diff --git a/src/components/scouting/player-performance-radar.tsx b/src/components/scouting/player-performance-radar.tsx index a8345ac0a..701018a18 100644 --- a/src/components/scouting/player-performance-radar.tsx +++ b/src/components/scouting/player-performance-radar.tsx @@ -8,7 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { ScoutingHeroPerformance } from "@/data/player-scouting-analytics-dto"; +import type { ScoutingHeroPerformance } from "@/data/player/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; diff --git a/src/components/scouting/player-profile-header.tsx b/src/components/scouting/player-profile-header.tsx index 740b38024..876971228 100644 --- a/src/components/scouting/player-profile-header.tsx +++ b/src/components/scouting/player-profile-header.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { PlayerProfile } from "@/data/player-scouting-dto"; +import type { PlayerProfile } from "@/data/player/types"; import { cn, format, toHero } from "@/lib/utils"; import { ExternalLink } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/player-scrim-overview.tsx b/src/components/scouting/player-scrim-overview.tsx index 96a854971..dc0c05ba6 100644 --- a/src/components/scouting/player-scrim-overview.tsx +++ b/src/components/scouting/player-scrim-overview.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { ScrimData } from "@/data/player-scouting-analytics-dto"; +import type { ScrimData } from "@/data/player/types"; import { cn } from "@/lib/utils"; import { Info } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scouting/player-search.tsx b/src/components/scouting/player-search.tsx index fbab5afb1..ea62dab2a 100644 --- a/src/components/scouting/player-search.tsx +++ b/src/components/scouting/player-search.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ScoutingPlayerSummary } from "@/data/player-scouting-dto"; +import type { ScoutingPlayerSummary } from "@/data/player/types"; import { cn } from "@/lib/utils"; import Fuse from "fuse.js"; import { Search } from "lucide-react"; diff --git a/src/components/scouting/player-strengths-weaknesses.tsx b/src/components/scouting/player-strengths-weaknesses.tsx index a1b527c67..b730a468a 100644 --- a/src/components/scouting/player-strengths-weaknesses.tsx +++ b/src/components/scouting/player-strengths-weaknesses.tsx @@ -9,7 +9,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { InsightItem } from "@/data/player-scouting-analytics-dto"; +import type { InsightItem } from "@/data/player/types"; import { cn } from "@/lib/utils"; import { AlertTriangle, diff --git a/src/components/scouting/player-tournament-history.tsx b/src/components/scouting/player-tournament-history.tsx index c15b3ab28..448f00ae5 100644 --- a/src/components/scouting/player-tournament-history.tsx +++ b/src/components/scouting/player-tournament-history.tsx @@ -7,7 +7,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { TournamentRecord } from "@/data/player-scouting-dto"; +import type { TournamentRecord } from "@/data/player/types"; import { cn, toHero } from "@/lib/utils"; import { ChevronDown, Info } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/scrim/swap-win-rate-chart.tsx b/src/components/scrim/swap-win-rate-chart.tsx index 114eeef5a..47bd28664 100644 --- a/src/components/scrim/swap-win-rate-chart.tsx +++ b/src/components/scrim/swap-win-rate-chart.tsx @@ -3,7 +3,7 @@ import type { SwapTimingOutcome, SwapWinrateBucket, -} from "@/data/team-hero-swap-dto"; +} from "@/data/team/types"; import { Bar, BarChart, diff --git a/src/components/stats/team/ability-impact-analysis-card.tsx b/src/components/stats/team/ability-impact-analysis-card.tsx index 87f9e7887..dd72dfe1c 100644 --- a/src/components/stats/team/ability-impact-analysis-card.tsx +++ b/src/components/stats/team/ability-impact-analysis-card.tsx @@ -26,7 +26,7 @@ import type { AbilityImpactData, AbilityScenarioStats, HeroAbilityImpact, -} from "@/data/team-ability-impact-dto"; +} from "@/data/team/types"; import { cn, toHero } from "@/lib/utils"; import { roleHeroMapping } from "@/types/heroes"; import { AlertTriangle, Check, ChevronsUpDown } from "lucide-react"; diff --git a/src/components/stats/team/best-role-trios-card.tsx b/src/components/stats/team/best-role-trios-card.tsx index a799ae489..124308076 100644 --- a/src/components/stats/team/best-role-trios-card.tsx +++ b/src/components/stats/team/best-role-trios-card.tsx @@ -7,7 +7,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { RoleTrio } from "@/data/team-role-stats-dto"; +import type { RoleTrio } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { ChevronDown, diff --git a/src/components/stats/team/hero-ban-impact-card.tsx b/src/components/stats/team/hero-ban-impact-card.tsx index 5a24cd6db..d9b5b540d 100644 --- a/src/components/stats/team/hero-ban-impact-card.tsx +++ b/src/components/stats/team/hero-ban-impact-card.tsx @@ -17,7 +17,7 @@ import { import type { CombinedBanAnalysis, HeroBanImpact, -} from "@/data/team-ban-impact-dto"; +} from "@/data/team/types"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import { AlertTriangle, ShieldOff } from "lucide-react"; import Image from "next/image"; diff --git a/src/components/stats/team/hero-our-bans-card.tsx b/src/components/stats/team/hero-our-bans-card.tsx index 19961fd19..f394d4a63 100644 --- a/src/components/stats/team/hero-our-bans-card.tsx +++ b/src/components/stats/team/hero-our-bans-card.tsx @@ -17,7 +17,7 @@ import { import type { OurBanImpact, TeamOurBanAnalysis, -} from "@/data/team-ban-impact-dto"; +} from "@/data/team/types"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import { Swords, TrendingUp } from "lucide-react"; import Image from "next/image"; diff --git a/src/components/stats/team/hero-pool-container.tsx b/src/components/stats/team/hero-pool-container.tsx index 584c0393b..9eb33af98 100644 --- a/src/components/stats/team/hero-pool-container.tsx +++ b/src/components/stats/team/hero-pool-container.tsx @@ -1,7 +1,7 @@ import { HeroPickrateHeatmap } from "@/components/stats/team/hero-pickrate-heatmap"; import { HeroPoolOverviewCard } from "@/components/stats/team/hero-pool-overview-card"; import type { HeroPickrateMatrix } from "@/data/team-analytics-dto"; -import type { HeroPoolAnalysis } from "@/data/team-hero-pool-dto"; +import type { HeroPoolAnalysis } from "@/data/team/types"; type HeroPoolContainerProps = { initialData: HeroPoolAnalysis; diff --git a/src/components/stats/team/hero-pool-overview-card.tsx b/src/components/stats/team/hero-pool-overview-card.tsx index 69932b761..4d384f16e 100644 --- a/src/components/stats/team/hero-pool-overview-card.tsx +++ b/src/components/stats/team/hero-pool-overview-card.tsx @@ -3,7 +3,7 @@ import { HeroWinratesCard } from "@/components/stats/team/hero-winrates-card"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { HeroPoolAnalysis } from "@/data/team-hero-pool-dto"; +import type { HeroPoolAnalysis } from "@/data/team/types"; import { cn, toHero, toTimestampWithHours, useHeroNames } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/stats/team/hero-winrates-card.tsx b/src/components/stats/team/hero-winrates-card.tsx index 5a0324bd1..1b6b92dd3 100644 --- a/src/components/stats/team/hero-winrates-card.tsx +++ b/src/components/stats/team/hero-winrates-card.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { HeroPoolAnalysis } from "@/data/team-hero-pool-dto"; +import type { HeroPoolAnalysis } from "@/data/team/types"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/stats/team/map-mode-performance-card.tsx b/src/components/stats/team/map-mode-performance-card.tsx index ec4eb1984..597cbb2fc 100644 --- a/src/components/stats/team/map-mode-performance-card.tsx +++ b/src/components/stats/team/map-mode-performance-card.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { MapModePerformance } from "@/data/team-map-mode-stats-dto"; +import type { MapModePerformance } from "@/data/team/types"; import { cn, toTimestampWithHours } from "@/lib/utils"; import { $Enums } from "@prisma/client"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/matchup-winrate-tab.tsx b/src/components/stats/team/matchup-winrate-tab.tsx index 3e9b08b95..4283ab3bc 100644 --- a/src/components/stats/team/matchup-winrate-tab.tsx +++ b/src/components/stats/team/matchup-winrate-tab.tsx @@ -24,7 +24,7 @@ import { import type { MatchupMapResult, MatchupWinrateData, -} from "@/data/team-matchup-winrate-dto"; +} from "@/data/team/types"; import { determineRole } from "@/lib/player-table-data"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; diff --git a/src/components/stats/team/quick-stats-card.tsx b/src/components/stats/team/quick-stats-card.tsx index bdd4bc37d..90d367803 100644 --- a/src/components/stats/team/quick-stats-card.tsx +++ b/src/components/stats/team/quick-stats-card.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { QuickWinsStats } from "@/data/team-quick-wins-dto"; +import type { QuickWinsStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { CalendarCheck, Clock, Trophy } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/recent-form-card.tsx b/src/components/stats/team/recent-form-card.tsx index 7719eeaa0..0ebfcc90c 100644 --- a/src/components/stats/team/recent-form-card.tsx +++ b/src/components/stats/team/recent-form-card.tsx @@ -9,7 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { RecentForm } from "@/data/team-performance-trends-dto"; +import type { RecentForm } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { TrendingDown, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/role-balance-radar.tsx b/src/components/stats/team/role-balance-radar.tsx index 36ca88d72..c18448666 100644 --- a/src/components/stats/team/role-balance-radar.tsx +++ b/src/components/stats/team/role-balance-radar.tsx @@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { RoleBalanceAnalysis, RolePerformanceStats, -} from "@/data/team-role-stats-dto"; +} from "@/data/team/types"; import { round } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { diff --git a/src/components/stats/team/role-performance-card.tsx b/src/components/stats/team/role-performance-card.tsx index c4af10b57..a99fb4ef6 100644 --- a/src/components/stats/team/role-performance-card.tsx +++ b/src/components/stats/team/role-performance-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { RolePerformanceStats } from "@/data/team-role-stats-dto"; +import type { RolePerformanceStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { Heart, Shield, Swords } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/swap-overview-card.tsx b/src/components/stats/team/swap-overview-card.tsx index 412c6a421..d22ca57fc 100644 --- a/src/components/stats/team/swap-overview-card.tsx +++ b/src/components/stats/team/swap-overview-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { TeamHeroSwapStats } from "@/data/team-hero-swap-dto"; +import type { TeamHeroSwapStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/swap-pairs-card.tsx b/src/components/stats/team/swap-pairs-card.tsx index 9cee0b13b..2d32939ea 100644 --- a/src/components/stats/team/swap-pairs-card.tsx +++ b/src/components/stats/team/swap-pairs-card.tsx @@ -6,7 +6,7 @@ import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; import type { SwapTimingBucket, TeamHeroSwapStats, -} from "@/data/team-hero-swap-dto"; +} from "@/data/team/types"; import { cn, toHero } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/stats/team/swap-player-breakdown-card.tsx b/src/components/stats/team/swap-player-breakdown-card.tsx index f5e1fe933..a569e5b0f 100644 --- a/src/components/stats/team/swap-player-breakdown-card.tsx +++ b/src/components/stats/team/swap-player-breakdown-card.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { TeamHeroSwapStats } from "@/data/team-hero-swap-dto"; +import type { TeamHeroSwapStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/swap-timing-card.tsx b/src/components/stats/team/swap-timing-card.tsx index a975daf06..cfa8be876 100644 --- a/src/components/stats/team/swap-timing-card.tsx +++ b/src/components/stats/team/swap-timing-card.tsx @@ -7,7 +7,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { TeamHeroSwapStats } from "@/data/team-hero-swap-dto"; +import type { TeamHeroSwapStats } from "@/data/team/types"; import { useTranslations } from "next-intl"; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/components/stats/team/swap-winrate-impact-card.tsx b/src/components/stats/team/swap-winrate-impact-card.tsx index da78dcadf..b67a59933 100644 --- a/src/components/stats/team/swap-winrate-impact-card.tsx +++ b/src/components/stats/team/swap-winrate-impact-card.tsx @@ -7,7 +7,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { TeamHeroSwapStats } from "@/data/team-hero-swap-dto"; +import type { TeamHeroSwapStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"; diff --git a/src/components/stats/team/team-fight-stats-card.tsx b/src/components/stats/team/team-fight-stats-card.tsx index 063a0c922..1adf2626d 100644 --- a/src/components/stats/team/team-fight-stats-card.tsx +++ b/src/components/stats/team/team-fight-stats-card.tsx @@ -5,7 +5,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { TeamFightStats } from "@/data/team-fight-stats-dto"; +import type { TeamFightStats } from "@/data/team/types"; import { round } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/ult-impact-analysis-card.tsx b/src/components/stats/team/ult-impact-analysis-card.tsx index 66eaf9dbd..7b998c4cb 100644 --- a/src/components/stats/team/ult-impact-analysis-card.tsx +++ b/src/components/stats/team/ult-impact-analysis-card.tsx @@ -25,7 +25,7 @@ import type { HeroUltImpact, ScenarioStats, UltImpactAnalysis, -} from "@/data/team-ult-impact-dto"; +} from "@/data/team/types"; import { cn, toHero } from "@/lib/utils"; import { roleHeroMapping } from "@/types/heroes"; import { AlertTriangle, Check, ChevronsUpDown } from "lucide-react"; diff --git a/src/components/stats/team/ult-player-rankings-card.tsx b/src/components/stats/team/ult-player-rankings-card.tsx index 099e043b8..513bc462c 100644 --- a/src/components/stats/team/ult-player-rankings-card.tsx +++ b/src/components/stats/team/ult-player-rankings-card.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { TeamUltStats } from "@/data/team-ult-stats-dto"; +import type { TeamUltStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/ult-role-breakdown-card.tsx b/src/components/stats/team/ult-role-breakdown-card.tsx index 063122496..7faa1e9e6 100644 --- a/src/components/stats/team/ult-role-breakdown-card.tsx +++ b/src/components/stats/team/ult-role-breakdown-card.tsx @@ -8,7 +8,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { TeamUltStats } from "@/data/team-ult-stats-dto"; +import type { TeamUltStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/ult-usage-overview-card.tsx b/src/components/stats/team/ult-usage-overview-card.tsx index 9649659ec..5f5d4b72b 100644 --- a/src/components/stats/team/ult-usage-overview-card.tsx +++ b/src/components/stats/team/ult-usage-overview-card.tsx @@ -12,7 +12,7 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import type { TeamUltStats } from "@/data/team-ult-stats-dto"; +import type { TeamUltStats } from "@/data/team/types"; import { cn, toHero } from "@/lib/utils"; import { ChevronDown } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/ultimate-economy-card.tsx b/src/components/stats/team/ultimate-economy-card.tsx index f34fb5370..6dee96735 100644 --- a/src/components/stats/team/ultimate-economy-card.tsx +++ b/src/components/stats/team/ultimate-economy-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { TeamFightStats } from "@/data/team-fight-stats-dto"; +import type { TeamFightStats } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/win-loss-streaks-card.tsx b/src/components/stats/team/win-loss-streaks-card.tsx index 6aa60f307..8479e341d 100644 --- a/src/components/stats/team/win-loss-streaks-card.tsx +++ b/src/components/stats/team/win-loss-streaks-card.tsx @@ -2,7 +2,7 @@ import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { StreakInfo } from "@/data/team-performance-trends-dto"; +import type { StreakInfo } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { Flame, TrendingDown, TrendingUp } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/win-probability-insights.tsx b/src/components/stats/team/win-probability-insights.tsx index a634e8e40..414c56bd3 100644 --- a/src/components/stats/team/win-probability-insights.tsx +++ b/src/components/stats/team/win-probability-insights.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { TeamFightStats } from "@/data/team-fight-stats-dto"; +import type { TeamFightStats } from "@/data/team/types"; import { useTranslations } from "next-intl"; type WinProbabilityInsightsProps = { diff --git a/src/components/stats/team/winrate-over-time-chart.tsx b/src/components/stats/team/winrate-over-time-chart.tsx index 6c6d1dbbe..f2c173890 100644 --- a/src/components/stats/team/winrate-over-time-chart.tsx +++ b/src/components/stats/team/winrate-over-time-chart.tsx @@ -8,7 +8,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import type { WinrateDataPoint } from "@/data/team-performance-trends-dto"; +import type { WinrateDataPoint } from "@/data/team/types"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { diff --git a/src/components/targets/player-target-detail.tsx b/src/components/targets/player-target-detail.tsx index febfcb073..35ff971dd 100644 --- a/src/components/targets/player-target-detail.tsx +++ b/src/components/targets/player-target-detail.tsx @@ -5,7 +5,7 @@ import { TargetForm } from "@/components/targets/target-form"; import { TargetNarrative } from "@/components/targets/target-narrative"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import type { ScrimStatPoint, TargetProgress } from "@/data/targets-dto"; +import type { ScrimStatPoint, TargetProgress } from "@/data/player/types"; import { type RoleName, getStatsForRole } from "@/lib/target-stats"; import type { PlayerTarget } from "@prisma/client"; import { ArrowLeft, Trash2 } from "lucide-react"; diff --git a/src/components/targets/player-targets-tab.tsx b/src/components/targets/player-targets-tab.tsx index c2e505d78..21c41e367 100644 --- a/src/components/targets/player-targets-tab.tsx +++ b/src/components/targets/player-targets-tab.tsx @@ -3,7 +3,7 @@ import { StatTrendChart } from "@/components/targets/stat-trend-chart"; import { TargetNarrative } from "@/components/targets/target-narrative"; import { TargetProgressCard } from "@/components/targets/target-progress-card"; -import type { ScrimStatPoint, TargetProgress } from "@/data/targets-dto"; +import type { ScrimStatPoint, TargetProgress } from "@/data/player/types"; import { getStatsForRole, type RoleName } from "@/lib/target-stats"; type Props = { diff --git a/src/components/targets/stat-trend-chart.tsx b/src/components/targets/stat-trend-chart.tsx index 6dca3a731..1131cdd3a 100644 --- a/src/components/targets/stat-trend-chart.tsx +++ b/src/components/targets/stat-trend-chart.tsx @@ -7,7 +7,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; -import type { ScrimStatPoint } from "@/data/targets-dto"; +import type { ScrimStatPoint } from "@/data/player/types"; import { getStatConfig } from "@/lib/target-stats"; import { format, round } from "@/lib/utils"; import type { PlayerTarget } from "@prisma/client"; diff --git a/src/components/targets/target-narrative.tsx b/src/components/targets/target-narrative.tsx index 43c1b81af..b18d2ff64 100644 --- a/src/components/targets/target-narrative.tsx +++ b/src/components/targets/target-narrative.tsx @@ -1,4 +1,4 @@ -import type { TargetProgress } from "@/data/targets-dto"; +import type { TargetProgress } from "@/data/player/types"; import { getStatConfig } from "@/lib/target-stats"; import { round } from "@/lib/utils"; diff --git a/src/components/targets/target-progress-card.tsx b/src/components/targets/target-progress-card.tsx index a6adb8768..a890cc3ac 100644 --- a/src/components/targets/target-progress-card.tsx +++ b/src/components/targets/target-progress-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { TargetProgress } from "@/data/targets-dto"; +import type { TargetProgress } from "@/data/player/types"; type Props = { progress: TargetProgress[]; diff --git a/src/components/targets/team-targets-overview.tsx b/src/components/targets/team-targets-overview.tsx index ecec1ad7f..84773015d 100644 --- a/src/components/targets/team-targets-overview.tsx +++ b/src/components/targets/team-targets-overview.tsx @@ -4,7 +4,7 @@ import { PlayerTargetDetail } from "@/components/targets/player-target-detail"; import { Sparkline } from "@/components/targets/sparkline"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; -import type { ScrimStatPoint, TargetProgress } from "@/data/targets-dto"; +import type { ScrimStatPoint, TargetProgress } from "@/data/player/types"; import type { RoleName } from "@/lib/target-stats"; import { cn } from "@/lib/utils"; import type { PlayerTarget } from "@prisma/client"; From 1f1f0f1a7eb8c61dff48d979c211dfdc0e25cdc4 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:22:31 -0400 Subject: [PATCH 25/42] Migrate lib files to new Effect services and type paths Switch ai/tools.ts to use 9 Effect services via AppRuntime. Update team-normalization.ts to use TeamSharedDataService. Redirect type imports in hero-pool-utils, insights, replay/build-player-timeline, and prediction-engine to new type paths. Leave calculate-stats, analytics, and mvp-score on old DTOs (client import chain). --- src/lib/ai/tools.ts | 94 ++++++++++++++++++------- src/lib/hero-pool-utils.ts | 2 +- src/lib/insights.ts | 10 +-- src/lib/prediction-engine.ts | 2 +- src/lib/replay/build-player-timeline.ts | 2 +- src/lib/team-normalization.ts | 10 ++- 6 files changed, 85 insertions(+), 35 deletions(-) diff --git a/src/lib/ai/tools.ts b/src/lib/ai/tools.ts index ba83d3928..40a00cbb6 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,23 +1,23 @@ import { ComparisonAggregationService } from "@/data/comparison"; -import { getMapIntelligence } from "@/data/map-intelligence-dto"; -import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; +import { + MapIntelligenceService, +} from "@/data/intelligence"; +import { IntelligenceService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; import { ScrimAbilityTimingService, ScrimOverviewService, } from "@/data/scrim"; -import { getTeamAbilityImpact } from "@/data/team-ability-impact-dto"; -import { Effect } from "effect"; -import { getTeamFightStats } from "@/data/team-fight-stats-dto"; -import { getHeroPoolAnalysis } from "@/data/team-hero-pool-dto"; import { - getRecentForm, - getStreakInfo, - getWinrateOverTime, -} from "@/data/team-performance-trends-dto"; -import { getRolePerformanceStats } from "@/data/team-role-stats-dto"; -import { getTeamRoster } from "@/data/team-shared-data"; -import { getTeamWinrates } from "@/data/team-stats-dto"; + TeamAbilityImpactService, + TeamFightStatsService, + TeamHeroPoolService, + TeamRoleStatsService, + TeamSharedDataService, + TeamStatsService, + TeamTrendsService, +} from "@/data/team"; +import { Effect } from "effect"; import prisma from "@/lib/prisma"; import type { HeroName } from "@/types/heroes"; import { allHeroes } from "@/types/heroes"; @@ -47,7 +47,11 @@ async function computeOpponentStats(scrimId: number, teamId: number) { const mapDataIds = scrim.maps.flatMap((m) => m.mapData.map((md) => md.id)); if (mapDataIds.length === 0) return { error: "No map data found." }; - const roster = await getTeamRoster(teamId); + const roster = await AppRuntime.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ) + ); const rosterSet = new Set(roster); const allStats = await prisma.playerStat.findMany({ @@ -416,7 +420,11 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const data = await getTeamWinrates(teamId); + const data = await AppRuntime.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId)) + ) + ); return formatTeamWinrates(data); }, }), @@ -431,11 +439,17 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const [winrateOverTime, recentForm, streakInfo] = await Promise.all([ - getWinrateOverTime(teamId), - getRecentForm(teamId), - getStreakInfo(teamId), - ]); + const [winrateOverTime, recentForm, streakInfo] = await AppRuntime.runPromise( + TeamTrendsService.pipe( + Effect.flatMap((svc) => + Effect.all([ + svc.getWinrateOverTime(teamId), + svc.getRecentForm(teamId), + svc.getStreakInfo(teamId), + ]) + ) + ) + ); return { winrateOverTime: winrateOverTime.map((dp) => ({ period: dp.period, @@ -474,7 +488,11 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const data = await getTeamFightStats(teamId); + const data = await AppRuntime.runPromise( + TeamFightStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamFightStats(teamId)) + ) + ); return data; }, }), @@ -487,7 +505,11 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const data = await getHeroPoolAnalysis(teamId); + const data = await AppRuntime.runPromise( + TeamHeroPoolService.pipe( + Effect.flatMap((svc) => svc.getHeroPoolAnalysis(teamId)) + ) + ); return data; }, }), @@ -500,7 +522,11 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const data = await getRolePerformanceStats(teamId); + const data = await AppRuntime.runPromise( + TeamRoleStatsService.pipe( + Effect.flatMap((svc) => svc.getRolePerformanceStats(teamId)) + ) + ); return data; }, }), @@ -519,7 +545,13 @@ export function buildTools(opts: { }), execute: async ({ teamId, opponentAbbr }) => { assertTeamAccess(teamId); - const data = await getPlayerIntelligence(teamId, opponentAbbr); + const data = await AppRuntime.runPromise( + IntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getPlayerIntelligence(teamId, opponentAbbr) + ) + ) + ); return data; }, }), @@ -540,7 +572,13 @@ export function buildTools(opts: { }), execute: async ({ opponentAbbr, teamId }) => { if (teamId) assertTeamAccess(teamId); - const data = await getMapIntelligence(opponentAbbr, teamId); + const data = await AppRuntime.runPromise( + MapIntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getMapIntelligence(opponentAbbr, teamId) + ) + ) + ); return data; }, }), @@ -624,7 +662,11 @@ export function buildTools(opts: { }), execute: async ({ teamId, heroes }) => { assertTeamAccess(teamId); - const data = await getTeamAbilityImpact(teamId); + const data = await AppRuntime.runPromise( + TeamAbilityImpactService.pipe( + Effect.flatMap((svc) => svc.getTeamAbilityImpact(teamId)) + ) + ); if (!data || Object.keys(data.byHero).length === 0) { return { error: "No ability impact data available for this team." }; diff --git a/src/lib/hero-pool-utils.ts b/src/lib/hero-pool-utils.ts index 8ce6d6d1e..6fdde5bb7 100644 --- a/src/lib/hero-pool-utils.ts +++ b/src/lib/hero-pool-utils.ts @@ -7,7 +7,7 @@ import type { HeroPoolRawData, HeroSpecialist, HeroWinrate, -} from "@/data/team-hero-pool-dto"; +} from "@/data/team/types"; import type { HeroName } from "@/types/heroes"; function findTeamNameForMapInMemory( diff --git a/src/lib/insights.ts b/src/lib/insights.ts index 5d8b54cea..f7bdbf95c 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -1,7 +1,9 @@ -import type { HeroBanIntelligence } from "@/data/hero-ban-intelligence-dto"; -import type { MapIntelligence } from "@/data/map-intelligence-dto"; -import type { TeamStrengthRating } from "@/data/scouting"; -import type { PlayerIntelligence } from "@/data/player-intelligence-dto"; +import type { + HeroBanIntelligence, + MapIntelligence, +} from "@/data/intelligence/types"; +import type { TeamStrengthRating } from "@/data/scouting/types"; +import type { PlayerIntelligence } from "@/data/player/types"; import { assessConfidence, isSufficientConfidence, diff --git a/src/lib/prediction-engine.ts b/src/lib/prediction-engine.ts index a161d39b9..5f09075fb 100644 --- a/src/lib/prediction-engine.ts +++ b/src/lib/prediction-engine.ts @@ -1,5 +1,5 @@ import type { SimulatorContext } from "@/data/team-prediction-dto"; -import type { RoleTrio } from "@/data/team-role-stats-dto"; +import type { RoleTrio } from "@/data/team/types"; import { mapNameToMapTypeMapping } from "@/types/map"; export type PredictionScenario = { diff --git a/src/lib/replay/build-player-timeline.ts b/src/lib/replay/build-player-timeline.ts index 1b2ef0514..f6202d812 100644 --- a/src/lib/replay/build-player-timeline.ts +++ b/src/lib/replay/build-player-timeline.ts @@ -2,7 +2,7 @@ import type { DisplayEvent, KillDisplayEvent, PositionSample, -} from "@/data/replay-dto"; +} from "@/data/map/replay/types"; export type PlayerTimeline = { playerName: string; diff --git a/src/lib/team-normalization.ts b/src/lib/team-normalization.ts index f3f563ebc..d00fc613c 100644 --- a/src/lib/team-normalization.ts +++ b/src/lib/team-normalization.ts @@ -1,14 +1,20 @@ import "server-only"; -import { getTeamRoster } from "@/data/team-shared-data"; +import { AppRuntime } from "@/data/runtime"; +import { TeamSharedDataService } from "@/data/team"; import { normalizeTeamData } from "@/lib/parser"; import type { ParserData } from "@/types/parser"; +import { Effect } from "effect"; export async function detectUserTeamSide( teamId: number, parsedData: ParserData ): Promise { - const roster = await getTeamRoster(teamId); + const roster = await AppRuntime.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ) + ); if (roster.length === 0) return false; const rosterSet = new Set(roster); From 3aebd106e9bd26fa1e98845399ae6e34399da093 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:31:27 -0400 Subject: [PATCH 26/42] Migrate all remaining server pages, routes, and components to native Effect Switch 22 files including the team stats page (14 service imports composed via Effect.all), tournament pages, scouting pages, player pages, map pages, and all remaining API routes to use Effect services via AppRuntime. Fix tournament service types for query payloads. --- .../scrim/[scrimId]/map/[mapId]/page.tsx | 6 +- .../map/[mapId]/player/[playerId]/page.tsx | 6 +- src/app/api/bot/team/route.ts | 10 +- .../tournament/[tournamentId]/route.ts | 12 +- src/app/api/compare/map-groups/[id]/route.ts | 26 +- src/app/api/compare/map-groups/route.ts | 24 +- src/app/api/compare/team-vs-team/route.ts | 12 +- src/app/api/team/targets/route.ts | 12 +- .../data-labeling/match/[matchId]/page.tsx | 10 +- src/app/demo/page.tsx | 8 +- src/app/demo/player/[playerId]/page.tsx | 8 +- src/app/profile/[playerName]/page.tsx | 28 ++- src/app/scouting/player/[slug]/page.tsx | 15 +- src/app/scouting/player/page.tsx | 8 +- src/app/scouting/team/[teamAbbr]/page.tsx | 37 ++- src/app/stats/hero/[heroName]/page.tsx | 27 ++- src/app/stats/team/[teamId]/page.tsx | 228 +++++++++++------- src/app/team/[teamId]/targets/page.tsx | 29 ++- .../tournaments/[id]/match/[matchId]/page.tsx | 10 +- src/app/tournaments/[id]/page.tsx | 26 +- src/app/tournaments/page.tsx | 10 +- src/data/tournament/tournament-service.ts | 74 +++++- 22 files changed, 439 insertions(+), 187 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index f5ead8ac0..3498c020b 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -22,7 +22,7 @@ import { MapTabsSkeleton } from "@/components/map/map-tabs-skeleton"; import { Suspense, ViewTransition } from "react"; import { UserNav } from "@/components/user-nav"; import { VodOverview } from "@/components/vods/vod-overview"; -import { getMostPlayedHeroes } from "@/data/player-dto"; +import { PlayerService } from "@/data/player"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -124,7 +124,9 @@ export default async function MapDashboardPage( tournamentEnabled, coachingCanvasEnabled, ] = await Promise.all([ - getMostPlayedHeroes(id), + AppRuntime.runPromise( + PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) + ), prisma.matchStart.findFirst({ where: { MapDataId: mapDataId }, select: { map_name: true, team_1_name: true }, diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index 54621e4a1..3acdd9705 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -12,7 +12,7 @@ import { DefaultOverview } from "@/components/player/default-overview"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserNav } from "@/components/user-nav"; -import { getMostPlayedHeroes } from "@/data/player-dto"; +import { PlayerService } from "@/data/player"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -68,7 +68,9 @@ export default async function PlayerDashboardPage( const mapDataId = await resolveMapDataId(id); const playerName = decodeURIComponent(params.playerId); - const mostPlayedHeroes = await getMostPlayedHeroes(id); + const mostPlayedHeroes = await AppRuntime.runPromise( + PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) + ); const mapName = await prisma.matchStart.findFirst({ where: { diff --git a/src/app/api/bot/team/route.ts b/src/app/api/bot/team/route.ts index ec4aff2db..b6c9c5d8c 100644 --- a/src/app/api/bot/team/route.ts +++ b/src/app/api/bot/team/route.ts @@ -1,4 +1,6 @@ -import { getTeamWinrates } from "@/data/team-stats-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TeamStatsService } from "@/data/team"; import { authenticateBotSecret, resolveDiscordUser, @@ -90,7 +92,11 @@ export async function GET(request: NextRequest) { _count: { select: { users: true } }, }, }), - getTeamWinrates(teamId), + AppRuntime.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId)) + ) + ), ]); if (!team) { diff --git a/src/app/api/broadcast/tournament/[tournamentId]/route.ts b/src/app/api/broadcast/tournament/[tournamentId]/route.ts index 500c3816a..0c0f301fe 100644 --- a/src/app/api/broadcast/tournament/[tournamentId]/route.ts +++ b/src/app/api/broadcast/tournament/[tournamentId]/route.ts @@ -1,4 +1,6 @@ -import { getTournamentBroadcastData } from "@/data/broadcast-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { BroadcastService } from "@/data/tournament"; import { Logger } from "@/lib/logger"; import { Ratelimit } from "@upstash/ratelimit"; import { ipAddress } from "@vercel/functions"; @@ -45,7 +47,13 @@ export async function GET( } event.tournamentId = tournamentId; - const data = await getTournamentBroadcastData(tournamentId); + const data = await AppRuntime.runPromise( + BroadcastService.pipe( + Effect.flatMap((svc) => + svc.getTournamentBroadcastData(tournamentId) + ) + ) + ); if (!data) { event.outcome = "not_found"; diff --git a/src/app/api/compare/map-groups/[id]/route.ts b/src/app/api/compare/map-groups/[id]/route.ts index ccdd2cf68..5aa90cae0 100644 --- a/src/app/api/compare/map-groups/[id]/route.ts +++ b/src/app/api/compare/map-groups/[id]/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { deleteMapGroup, updateMapGroup } from "@/data/map-group-dto"; +import { MapGroupService } from "@/data/map"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -155,12 +155,18 @@ export async function PUT( const { name, description, mapIds, category } = validatedData.data; - const updatedGroup = await updateMapGroup(groupId, { - name, - description, - mapIds, - category, - }); + const updatedGroup = await AppRuntime.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => + svc.updateMapGroup(groupId, { + name, + description, + mapIds, + category, + }) + ) + ) + ); const groupWithCreator = await prisma.mapGroup.findUnique({ where: { id: updatedGroup.id }, @@ -325,7 +331,11 @@ export async function DELETE( is_admin: isAdmin, }; - await deleteMapGroup(groupId); + await AppRuntime.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => svc.deleteMapGroup(groupId)) + ) + ); wideEvent.status_code = 200; wideEvent.outcome = "success"; diff --git a/src/app/api/compare/map-groups/route.ts b/src/app/api/compare/map-groups/route.ts index 8b5adc49b..b750750be 100644 --- a/src/app/api/compare/map-groups/route.ts +++ b/src/app/api/compare/map-groups/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { createMapGroup } from "@/data/map-group-dto"; +import { MapGroupService } from "@/data/map"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -228,14 +228,20 @@ export async function POST(request: NextRequest) { category, }; - const group = await createMapGroup({ - name, - description, - teamId, - mapIds, - category, - createdBy: user.id, - }); + const group = await AppRuntime.runPromise( + MapGroupService.pipe( + Effect.flatMap((svc) => + svc.createMapGroup({ + name, + description, + teamId, + mapIds, + category, + createdBy: user.id, + }) + ) + ) + ); const groupWithCreator = await prisma.mapGroup.findUnique({ where: { id: group.id }, diff --git a/src/app/api/compare/team-vs-team/route.ts b/src/app/api/compare/team-vs-team/route.ts index c0bc75708..9263c673c 100644 --- a/src/app/api/compare/team-vs-team/route.ts +++ b/src/app/api/compare/team-vs-team/route.ts @@ -1,4 +1,6 @@ -import { getTeamComparisonStats } from "@/data/team-comparison-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TeamComparisonService } from "@/data/team"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import { NextResponse } from "next/server"; @@ -41,7 +43,13 @@ export async function GET(request: Request) { ? (heroesParam.split(",") as HeroName[]) : undefined; - const stats = await getTeamComparisonStats(mapIds, teamId, heroes); + const stats = await AppRuntime.runPromise( + TeamComparisonService.pipe( + Effect.flatMap((svc) => + svc.getTeamComparisonStats(mapIds, teamId, heroes) + ) + ) + ); return NextResponse.json({ data: stats }); } catch (error) { diff --git a/src/app/api/team/targets/route.ts b/src/app/api/team/targets/route.ts index 39e34a86d..8721661b1 100644 --- a/src/app/api/team/targets/route.ts +++ b/src/app/api/team/targets/route.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { getRecentScrimStats } from "@/data/targets-dto"; +import { TargetsService } from "@/data/player"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; @@ -95,10 +95,12 @@ export async function POST(req: NextRequest) { if (!hasPerms) unauthorized(); // Calculate baseline from recent scrims - const recentStats = await getRecentScrimStats( - playerName, - teamId, - scrimWindow + const recentStats = await AppRuntime.runPromise( + TargetsService.pipe( + Effect.flatMap((svc) => + svc.getRecentScrimStats(playerName, teamId, scrimWindow) + ) + ) ); let baselineValue = 0; if (recentStats.length > 0) { diff --git a/src/app/data-labeling/match/[matchId]/page.tsx b/src/app/data-labeling/match/[matchId]/page.tsx index 48bb3bde9..18f5d93fa 100644 --- a/src/app/data-labeling/match/[matchId]/page.tsx +++ b/src/app/data-labeling/match/[matchId]/page.tsx @@ -1,5 +1,7 @@ import { MatchLabelingView } from "@/components/data-labeling/match-labeling-view"; -import { getMatchForLabeling } from "@/data/data-labeling-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { DataLabelingService } from "@/data/admin"; import { dataLabeling } from "@/lib/flags"; import { notFound } from "next/navigation"; @@ -17,7 +19,11 @@ export default async function MatchLabelingPage({ const id = Number(matchId); if (Number.isNaN(id)) notFound(); - const match = await getMatchForLabeling(id); + const match = await AppRuntime.runPromise( + DataLabelingService.pipe( + Effect.flatMap((svc) => svc.getMatchForLabeling(id)) + ) + ); if (!match) notFound(); return ( diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index b8614eb63..7a5077f72 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -10,7 +10,9 @@ import { MapEvents } from "@/components/map/map-events"; import { PlayerSwitcher } from "@/components/map/player-switcher"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getMostPlayedHeroes } from "@/data/player-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { PlayerService } from "@/data/player"; import { tempoChart } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { toTitleCase, translateMapName } from "@/lib/utils"; @@ -65,7 +67,9 @@ export default async function MapDashboardPage() { const t = await getTranslations("mapPage"); const id = 268; - const mostPlayedHeroes = await getMostPlayedHeroes(id); + const mostPlayedHeroes = await AppRuntime.runPromise( + PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) + ); const mapDetails = await prisma.matchStart.findFirst({ where: { diff --git a/src/app/demo/player/[playerId]/page.tsx b/src/app/demo/player/[playerId]/page.tsx index 645c85e47..6f0b0f2fc 100644 --- a/src/app/demo/player/[playerId]/page.tsx +++ b/src/app/demo/player/[playerId]/page.tsx @@ -7,7 +7,9 @@ import { PlayerAnalytics } from "@/components/player/analytics"; import { DefaultOverview } from "@/components/player/default-overview"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getMostPlayedHeroes } from "@/data/player-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { PlayerService } from "@/data/player"; import prisma from "@/lib/prisma"; import { toTitleCase } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; @@ -56,7 +58,9 @@ export default async function PlayerDashboardDemoPage( const id = 268; const playerName = decodeURIComponent(params.playerId); - const mostPlayedHeroes = await getMostPlayedHeroes(id); + const mostPlayedHeroes = await AppRuntime.runPromise( + PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) + ); const mapName = await prisma.matchStart.findFirst({ where: { diff --git a/src/app/profile/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx index 517f675a8..e2569dc2b 100644 --- a/src/app/profile/[playerName]/page.tsx +++ b/src/app/profile/[playerName]/page.tsx @@ -16,10 +16,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ScrimService } from "@/data/scrim"; import { calculateTargetProgress, - getPlayerTargets, - getRecentScrimStats, + TargetsService, type TargetProgress, -} from "@/data/targets-dto"; +} from "@/data/player"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -297,7 +296,12 @@ export default async function ProfilePage( const canViewTargets = isOwnProfile ?? isAdmin; let targetProgress: TargetProgress[] = []; - let targetScrimStats: Awaited> = []; + let targetScrimStats: { + scrimId: number; + scrimDate: string; + scrimName: string; + stats: Record; + }[] = []; let playerPrimaryRole: RoleName = "Damage"; if (canViewTargets) { @@ -319,13 +323,19 @@ export default async function ProfilePage( if (topRole) playerPrimaryRole = topRole; } - const targets = await getPlayerTargets(targetTeamId, name); + const targets = await AppRuntime.runPromise( + TargetsService.pipe( + Effect.flatMap((svc) => svc.getPlayerTargets(targetTeamId, name)) + ) + ); if (targets.length > 0) { const maxWindow = Math.max(...targets.map((t) => t.scrimWindow)); - targetScrimStats = await getRecentScrimStats( - name, - targetTeamId, - maxWindow + targetScrimStats = await AppRuntime.runPromise( + TargetsService.pipe( + Effect.flatMap((svc) => + svc.getRecentScrimStats(name, targetTeamId, maxWindow) + ) + ) ); targetProgress = targets.map((target) => { const windowStats = targetScrimStats.slice(-target.scrimWindow); diff --git a/src/app/scouting/player/[slug]/page.tsx b/src/app/scouting/player/[slug]/page.tsx index 9585ab84c..d278fa97b 100644 --- a/src/app/scouting/player/[slug]/page.tsx +++ b/src/app/scouting/player/[slug]/page.tsx @@ -7,8 +7,9 @@ import { PlayerProfileHeader } from "@/components/scouting/player-profile-header import { PlayerScrimOverview } from "@/components/scouting/player-scrim-overview"; import { PlayerStrengthsWeaknesses } from "@/components/scouting/player-strengths-weaknesses"; import { PlayerTournamentHistory } from "@/components/scouting/player-tournament-history"; -import { getPlayerScoutingAnalytics } from "@/data/player-scouting-analytics-dto"; -import { getPlayerProfile } from "@/data/player-scouting-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { ScoutingService, ScoutingAnalyticsService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; import { ArrowLeft } from "lucide-react"; import { getTranslations } from "next-intl/server"; @@ -25,10 +26,16 @@ export default async function ScoutingPlayerPage( const slug = decodeURIComponent(params.slug); const t = await getTranslations("scoutingPage.player.profile"); - const profile = await getPlayerProfile(slug); + const profile = await AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getPlayerProfile(slug))) + ); if (!profile) notFound(); - const analytics = await getPlayerScoutingAnalytics(profile.name); + const analytics = await AppRuntime.runPromise( + ScoutingAnalyticsService.pipe( + Effect.flatMap((svc) => svc.getPlayerScoutingAnalytics(profile.name)) + ) + ); return (
diff --git a/src/app/scouting/player/page.tsx b/src/app/scouting/player/page.tsx index 7ae521a0b..b98c160f4 100644 --- a/src/app/scouting/player/page.tsx +++ b/src/app/scouting/player/page.tsx @@ -1,5 +1,7 @@ import { PlayerSearch } from "@/components/scouting/player-search"; -import { getScoutingPlayers } from "@/data/player-scouting-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { ScoutingService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -9,7 +11,9 @@ export default async function ScoutPlayerPage() { if (!scoutingEnabled) notFound(); const t = await getTranslations("scoutingPage.player"); - const players = await getScoutingPlayers(); + const players = await AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingPlayers())) + ); return (
diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index 37466b0e6..b80924c4c 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -8,9 +8,8 @@ import { ScoutForTeamPicker } from "@/components/scouting/scout-for-team-picker" import { ScoutingReport } from "@/components/scouting/scouting-report"; import { TeamOverviewEnhanced } from "@/components/scouting/team-overview-enhanced"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { getHeroBanIntelligence } from "@/data/hero-ban-intelligence-dto"; -import { getMapIntelligence } from "@/data/map-intelligence-dto"; -import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; +import { HeroBanIntelligenceService, MapIntelligenceService } from "@/data/intelligence"; +import { IntelligenceService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; import { OpponentStrengthService, ScoutingService } from "@/data/scouting"; import { Effect } from "effect"; @@ -116,13 +115,31 @@ export default async function ScoutingTeamPage( ]); const [mapIntelligence, banIntelligence, playerIntelligence] = - await Promise.all([ - getMapIntelligence(teamAbbr, userTeamId, dataAvailability), - getHeroBanIntelligence(teamAbbr, userTeamId, dataAvailability), - userTeamId - ? getPlayerIntelligence(userTeamId, teamAbbr, dataAvailability) - : Promise.resolve(null), - ]); + await AppRuntime.runPromise( + Effect.all({ + mapIntelligence: MapIntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getMapIntelligence(teamAbbr, userTeamId, dataAvailability) + ) + ), + banIntelligence: HeroBanIntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getHeroBanIntelligence(teamAbbr, userTeamId, dataAvailability) + ) + ), + playerIntelligence: userTeamId + ? IntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getPlayerIntelligence( + userTeamId, + teamAbbr, + dataAvailability + ) + ) + ) + : Effect.succeed(null), + }, { concurrency: "unbounded" }) + ).then((r) => [r.mapIntelligence, r.banIntelligence, r.playerIntelligence] as const); const insightReport = generateInsights({ mapIntelligence, diff --git a/src/app/stats/hero/[heroName]/page.tsx b/src/app/stats/hero/[heroName]/page.tsx index 931403751..20fae4be3 100644 --- a/src/app/stats/hero/[heroName]/page.tsx +++ b/src/app/stats/hero/[heroName]/page.tsx @@ -4,11 +4,7 @@ import { } from "@/components/stats/hero/range-picker"; import { Card } from "@/components/ui/card"; import { Link } from "@/components/ui/link"; -import { - getAllDeathsForHero, - getAllKillsForHero, - getAllStatsForHero, -} from "@/data/hero-dto"; +import { HeroService } from "@/data/hero"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -146,11 +142,22 @@ export default async function HeroStats( let allHeroDeaths: Kill[]; try { - [allHeroStats, allHeroKills, allHeroDeaths] = await Promise.all([ - getAllStatsForHero(allScrimIds, hero), - getAllKillsForHero(allScrimIds, hero), - getAllDeathsForHero(allScrimIds, hero), - ]); + [allHeroStats, allHeroKills, allHeroDeaths] = await AppRuntime.runPromise( + Effect.all( + [ + HeroService.pipe( + Effect.flatMap((svc) => svc.getAllStatsForHero(allScrimIds, hero)) + ), + HeroService.pipe( + Effect.flatMap((svc) => svc.getAllKillsForHero(allScrimIds, hero)) + ), + HeroService.pipe( + Effect.flatMap((svc) => svc.getAllDeathsForHero(allScrimIds, hero)) + ), + ], + { concurrency: "unbounded" } + ) + ); } catch { return (
diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx index 3b9356155..18077fdb1 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -33,39 +33,25 @@ import { WinProbabilityInsights } from "@/components/stats/team/win-probability- import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - getHeroPickrateRawData, - getPlayerMapPerformanceMatrix, -} from "@/data/team-analytics-dto"; -import { getTeamBanImpactAnalysis } from "@/data/team-ban-impact-dto"; -import { getMatchupWinrateData } from "@/data/team-matchup-winrate-dto"; -import { getTeamFightStats } from "@/data/team-fight-stats-dto"; -import { getHeroPoolAnalysis } from "@/data/team-hero-pool-dto"; -import { getTeamHeroSwapStats } from "@/data/team-hero-swap-dto"; -import { getMapModePerformance } from "@/data/team-map-mode-stats-dto"; + TeamAnalyticsService, + TeamPredictionService, +} from "@/data/team"; import { - getRecentForm, - getStreakInfo, - getWinrateOverTime, -} from "@/data/team-performance-trends-dto"; -import { getSimulatorContext } from "@/data/team-prediction-dto"; -import { getQuickWinsStats } from "@/data/team-quick-wins-dto"; -import { - getBestRoleTrios, - getRoleBalanceAnalysis, - getRolePerformanceStats, -} from "@/data/team-role-stats-dto"; -import type { TeamDateRange } from "@/data/team-shared-core"; -import { - getBestMapByWinrate, - getBlindSpotMap, - getTeamRoster, - getTeamWinrates, - getTop5MapsByPlaytime, - getTopMapsByPlaytime, -} from "@/data/team-stats-dto"; -import { getTeamAbilityImpact } from "@/data/team-ability-impact-dto"; -import { getTeamUltImpact } from "@/data/team-ult-impact-dto"; -import { getTeamUltStats } from "@/data/team-ult-stats-dto"; + type TeamDateRange, + TeamStatsService, + TeamFightStatsService, + TeamRoleStatsService, + TeamTrendsService, + TeamMapModeService, + TeamQuickWinsService, + TeamHeroPoolService, + TeamHeroSwapService, + TeamBanImpactService, + TeamAbilityImpactService, + TeamUltService, + TeamMatchupService, + TeamSharedDataService, +} from "@/data/team"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -184,76 +170,148 @@ export default async function TeamStatsPage( const dateRange = computeDateRange(effectiveTimeframe, customFrom, customTo); const [ + { + teamRoster, + winrates, + top5Maps, + allMapsPlaytime, + bestMapByWinrate, + blindSpotMap, + fightStats, + roleStats, + roleBalance, + bestTrios, + weeklyWinrate, + monthlyWinrate, + recentForm, + streakInfo, + mapModePerformance, + quickStats, + ultStats, + heroSwapStats, + banImpactAnalysis, + ultImpactAnalysis, + abilityImpactAnalysis, + matchupWinrateData, + heroPool, + }, scrims, - teamRoster, - winrates, - top5Maps, - allMapsPlaytime, - bestMapByWinrate, - blindSpotMap, - fightStats, mapNames, - roleStats, - roleBalance, - bestTrios, - weeklyWinrate, - monthlyWinrate, - recentForm, - streakInfo, - mapModePerformance, - quickStats, playerMapPerformance, - ultStats, - heroSwapStats, - banImpactAnalysis, simulatorContext, simulationToolEnabled, - ultImpactAnalysis, ultimateImpactToolEnabled, - abilityImpactAnalysis, - matchupWinrateData, + heroPickrateRawData, ] = await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + teamRoster: TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ), + winrates: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ), + top5Maps: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTop5MapsByPlaytime(teamId, dateRange)) + ), + allMapsPlaytime: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) + ), + bestMapByWinrate: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBestMapByWinrate(teamId, dateRange)) + ), + blindSpotMap: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBlindSpotMap(teamId, dateRange)) + ), + fightStats: TeamFightStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamFightStats(teamId, dateRange)) + ), + roleStats: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => + svc.getRolePerformanceStats(teamId, dateRange) + ) + ), + roleBalance: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => + svc.getRoleBalanceAnalysis(teamId, dateRange) + ) + ), + bestTrios: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => svc.getBestRoleTrios(teamId, dateRange)) + ), + weeklyWinrate: TeamTrendsService.pipe( + Effect.flatMap((svc) => + svc.getWinrateOverTime(teamId, "week", dateRange) + ) + ), + monthlyWinrate: TeamTrendsService.pipe( + Effect.flatMap((svc) => + svc.getWinrateOverTime(teamId, "month", dateRange) + ) + ), + recentForm: TeamTrendsService.pipe( + Effect.flatMap((svc) => svc.getRecentForm(teamId, dateRange)) + ), + streakInfo: TeamTrendsService.pipe( + Effect.flatMap((svc) => svc.getStreakInfo(teamId, dateRange)) + ), + mapModePerformance: TeamMapModeService.pipe( + Effect.flatMap((svc) => + svc.getMapModePerformance(teamId, dateRange) + ) + ), + quickStats: TeamQuickWinsService.pipe( + Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) + ), + ultStats: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltStats(teamId, dateRange)) + ), + heroSwapStats: TeamHeroSwapService.pipe( + Effect.flatMap((svc) => + svc.getTeamHeroSwapStats(teamId, dateRange) + ) + ), + banImpactAnalysis: TeamBanImpactService.pipe( + Effect.flatMap((svc) => + svc.getTeamBanImpactAnalysis(teamId, dateRange) + ) + ), + ultImpactAnalysis: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltImpact(teamId, dateRange)) + ), + abilityImpactAnalysis: TeamAbilityImpactService.pipe( + Effect.flatMap((svc) => + svc.getTeamAbilityImpact(teamId, dateRange) + ) + ), + matchupWinrateData: TeamMatchupService.pipe( + Effect.flatMap((svc) => + svc.getMatchupWinrateData(teamId, dateRange) + ) + ), + heroPool: TeamHeroPoolService.pipe( + Effect.flatMap((svc) => + svc.getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to) + ) + ), + }, + { concurrency: "unbounded" } + ) + ), prisma.scrim.findMany({ where: { teamId, ...(dateRange && { date: { gte: dateRange.from, lte: dateRange.to } }), }, }), - getTeamRoster(teamId), - getTeamWinrates(teamId, dateRange), - getTop5MapsByPlaytime(teamId, dateRange), - getTopMapsByPlaytime(teamId, dateRange), - getBestMapByWinrate(teamId, dateRange), - getBlindSpotMap(teamId, dateRange), - getTeamFightStats(teamId, dateRange), getMapNames(), - getRolePerformanceStats(teamId, dateRange), - getRoleBalanceAnalysis(teamId, dateRange), - getBestRoleTrios(teamId, dateRange), - getWinrateOverTime(teamId, "week", dateRange), - getWinrateOverTime(teamId, "month", dateRange), - getRecentForm(teamId, dateRange), - getStreakInfo(teamId, dateRange), - getMapModePerformance(teamId, dateRange), - getQuickWinsStats(teamId, dateRange), - getPlayerMapPerformanceMatrix(teamId, dateRange), - getTeamUltStats(teamId, dateRange), - getTeamHeroSwapStats(teamId, dateRange), - getTeamBanImpactAnalysis(teamId, dateRange), - getSimulatorContext(teamId, dateRange), + AppRuntime.runPromise(TeamAnalyticsService.pipe(Effect.flatMap((svc) => svc.getPlayerMapPerformanceMatrix(teamId, dateRange)))), + AppRuntime.runPromise(TeamPredictionService.pipe(Effect.flatMap((svc) => svc.getSimulatorContext(teamId, dateRange)))), simulationTool(), - getTeamUltImpact(teamId, dateRange), ultimateImpactTool(), - getTeamAbilityImpact(teamId, dateRange), - getMatchupWinrateData(teamId, dateRange), + AppRuntime.runPromise(TeamAnalyticsService.pipe(Effect.flatMap((svc) => svc.getHeroPickrateRawData(teamId, dateRange)))), ]); - - const heroPickrateRawData = await getHeroPickrateRawData(teamId, dateRange); - const heroPool = await getHeroPoolAnalysis( - teamId, - dateRange?.from, - dateRange?.to - ); const heroPickrateMatrix = calculateHeroPickrateMatrix(heroPickrateRawData); const mapPlaytimes: Record = {}; diff --git a/src/app/team/[teamId]/targets/page.tsx b/src/app/team/[teamId]/targets/page.tsx index 09d2cb2d4..c00687928 100644 --- a/src/app/team/[teamId]/targets/page.tsx +++ b/src/app/team/[teamId]/targets/page.tsx @@ -2,11 +2,10 @@ import { TeamTargetsOverview } from "@/components/targets/team-targets-overview" import { Link } from "@/components/ui/link"; import { calculateTargetProgress, - getRecentScrimStats, - getTeamTargets, + TargetsService, type TargetProgress, -} from "@/data/targets-dto"; -import { getTeamRoster } from "@/data/team-shared-data"; +} from "@/data/player"; +import { TeamSharedDataService } from "@/data/team"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -89,7 +88,11 @@ export default async function TeamTargetsPage(props: Props) { // Use scrim-based roster (same as team stats page) instead of team membership const [roster, teamMembersData, targetsByPlayer] = await Promise.all([ - getTeamRoster(teamId), + AppRuntime.runPromise( + TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ) + ), prisma.team.findFirst({ where: { id: teamId }, select: { @@ -98,7 +101,11 @@ export default async function TeamTargetsPage(props: Props) { }, }, }), - getTeamTargets(teamId), + AppRuntime.runPromise( + TargetsService.pipe( + Effect.flatMap((svc) => svc.getTeamTargets(teamId)) + ) + ), ]); // Build a lookup of registered team members by name/battletag (case-insensitive) @@ -148,10 +155,12 @@ export default async function TeamTargetsPage(props: Props) { targets.length > 0 ? Math.max(...targets.map((t) => t.scrimWindow)) : 10; - const scrimStats = await getRecentScrimStats( - playerName, - teamId, - maxWindow + const scrimStats = await AppRuntime.runPromise( + TargetsService.pipe( + Effect.flatMap((svc) => + svc.getRecentScrimStats(playerName, teamId, maxWindow) + ) + ) ); // Calculate progress for each target diff --git a/src/app/tournaments/[id]/match/[matchId]/page.tsx b/src/app/tournaments/[id]/match/[matchId]/page.tsx index e18cda501..824e13ae5 100644 --- a/src/app/tournaments/[id]/match/[matchId]/page.tsx +++ b/src/app/tournaments/[id]/match/[matchId]/page.tsx @@ -3,7 +3,9 @@ import { MatchMapsPanel } from "@/components/tournament/match/match-maps-panel"; import { TeamPanel } from "@/components/tournament/match/team-panel"; import { TournamentAddMapCard } from "@/components/tournament/match/tournament-add-map-card"; import { Badge } from "@/components/ui/badge"; -import { getTournamentMatch } from "@/data/tournament-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TournamentService } from "@/data/tournament"; import { auth } from "@/lib/auth"; import { tournament } from "@/lib/flags"; import prisma from "@/lib/prisma"; @@ -23,7 +25,11 @@ export default async function TournamentMatchPage(props: { const matchId = Number(params.matchId); if (Number.isNaN(tournamentId) || Number.isNaN(matchId)) notFound(); - const match = await getTournamentMatch(matchId); + const match = await AppRuntime.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getTournamentMatch(matchId)) + ) + ); if (!match || match.tournamentId !== tournamentId) notFound(); const isCompleted = match.status === "COMPLETED"; diff --git a/src/app/tournaments/[id]/page.tsx b/src/app/tournaments/[id]/page.tsx index cd34113b3..c8f21d920 100644 --- a/src/app/tournaments/[id]/page.tsx +++ b/src/app/tournaments/[id]/page.tsx @@ -5,7 +5,9 @@ import type { BracketMatchData } from "@/components/tournament/bracket/bracket-m import { RoundRobinSEView } from "@/components/tournament/round-robin/round-robin-se-view"; import { TournamentActions } from "@/components/tournament/tournament-actions"; import { Badge } from "@/components/ui/badge"; -import { getRRStandings, getTournamentBracket } from "@/data/tournament-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TournamentService } from "@/data/tournament"; import { auth } from "@/lib/auth"; import { tournament } from "@/lib/flags"; import prisma from "@/lib/prisma"; @@ -24,14 +26,30 @@ export default async function TournamentDetailPage(props: { const id = Number(params.id); if (Number.isNaN(id)) notFound(); - const data = await getTournamentBracket(id); + const data = await AppRuntime.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getTournamentBracket(id)) + ) + ); if (!data) notFound(); - let rrStandings: Awaited> = []; + let rrStandings: { + teamId: number; + teamName: string; + matchesWon: number; + matchesLost: number; + mapsWon: number; + mapsLost: number; + mapDifferential: number; + }[] = []; let canManage = false; if (data.format === "ROUND_ROBIN_SE") { - rrStandings = await getRRStandings(id); + rrStandings = await AppRuntime.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getRRStandings(id)) + ) + ); const session = await auth(); if (session?.user?.email) { diff --git a/src/app/tournaments/page.tsx b/src/app/tournaments/page.tsx index 8031d481c..3217d03e1 100644 --- a/src/app/tournaments/page.tsx +++ b/src/app/tournaments/page.tsx @@ -1,7 +1,9 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { CreateTournamentButton } from "@/components/tournament/create-tournament-button"; import { TournamentCard } from "@/components/tournament/tournament-card"; -import { getUserTournaments } from "@/data/tournament-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { TournamentService } from "@/data/tournament"; import { auth } from "@/lib/auth"; import { tournament } from "@/lib/flags"; import { notFound, redirect } from "next/navigation"; @@ -13,7 +15,11 @@ export default async function TournamentsPage() { const session = await auth(); if (!session?.user?.id) redirect("/"); - const tournaments = await getUserTournaments(session.user.id); + const tournaments = await AppRuntime.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getUserTournaments(session.user.id!)) + ) + ); return ( diff --git a/src/data/tournament/tournament-service.ts b/src/data/tournament/tournament-service.ts index ff9135904..892e9a714 100644 --- a/src/data/tournament/tournament-service.ts +++ b/src/data/tournament/tournament-service.ts @@ -26,21 +26,73 @@ import { tournamentCacheMissTotal, } from "./metrics"; +import type { Prisma } from "@prisma/client"; + type GetTournamentResult = Awaited< ReturnType >; -type GetUserTournamentsResult = Awaited< - ReturnType ->; - -type GetTournamentMatchResult = Awaited< - ReturnType ->; - -type GetTournamentBracketResult = Awaited< - ReturnType ->; +type GetUserTournamentsResult = Prisma.TournamentGetPayload<{ + include: { + teams: { select: { name: true } }; + _count: { select: { matches: true } }; + }; +}>[]; + +type GetTournamentMatchResult = Prisma.TournamentMatchGetPayload<{ + include: { + tournament: true; + round: true; + team1: { + include: { + team: { select: { id: true; name: true; image: true } }; + }; + }; + team2: { + include: { + team: { select: { id: true; name: true; image: true } }; + }; + }; + winner: true; + maps: { + include: { + map: { + include: { + mapData: { + include: { + match_start: true; + match_end: true; + round_end: true; + HeroBan: true; + }; + }; + }; + }; + }; + }; + }; +}> | null; + +type GetTournamentBracketResult = Prisma.TournamentGetPayload<{ + include: { + teams: true; + rounds: true; + matches: { + include: { + team1: { select: { id: true; name: true; seed: true } }; + team2: { select: { id: true; name: true; seed: true } }; + winner: { select: { id: true; name: true } }; + round: { + select: { + roundNumber: true; + roundName: true; + bracket: true; + }; + }; + }; + }; + }; +}> | null; export type TournamentServiceInterface = { readonly getTournament: ( From 9e0040779f976da994cf69d87155f3a98db711a9 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:31:41 -0400 Subject: [PATCH 27/42] Migrate final consumer files and fix client import chain Break the static import chain from scrim-creator.tsx (client) through parser.ts to calculate-stats.ts by using dynamic import(). This allows calculate-stats.ts, analytics.ts, and mvp-score.ts to safely import AppRuntime and use Effect services. Migrate remaining component and lib type imports to new paths. --- .../stats/team/hero-pickrate-heatmap.tsx | 2 +- src/components/stats/team/hero-pool-container.tsx | 2 +- .../stats/team/player-map-performance-card.tsx | 2 +- src/components/stats/team/simulator-tab.tsx | 2 +- src/lib/analytics.ts | 14 +++++++++++--- src/lib/hero-pickrate-utils.ts | 2 +- src/lib/parser.ts | 7 ++++++- src/lib/prediction-engine.ts | 2 +- 8 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/components/stats/team/hero-pickrate-heatmap.tsx b/src/components/stats/team/hero-pickrate-heatmap.tsx index 9fbda35bb..4d69acc24 100644 --- a/src/components/stats/team/hero-pickrate-heatmap.tsx +++ b/src/components/stats/team/hero-pickrate-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { HeroPickrateMatrix } from "@/data/team-analytics-dto"; +import type { HeroPickrateMatrix } from "@/data/team/types"; import { cn, toHero, toTimestampWithHours, useHeroNames } from "@/lib/utils"; import { heroPriority, heroRoleMapping } from "@/types/heroes"; import { useTranslations } from "next-intl"; diff --git a/src/components/stats/team/hero-pool-container.tsx b/src/components/stats/team/hero-pool-container.tsx index 9eb33af98..3a0d5a4c8 100644 --- a/src/components/stats/team/hero-pool-container.tsx +++ b/src/components/stats/team/hero-pool-container.tsx @@ -1,6 +1,6 @@ import { HeroPickrateHeatmap } from "@/components/stats/team/hero-pickrate-heatmap"; import { HeroPoolOverviewCard } from "@/components/stats/team/hero-pool-overview-card"; -import type { HeroPickrateMatrix } from "@/data/team-analytics-dto"; +import type { HeroPickrateMatrix } from "@/data/team/types"; import type { HeroPoolAnalysis } from "@/data/team/types"; type HeroPoolContainerProps = { diff --git a/src/components/stats/team/player-map-performance-card.tsx b/src/components/stats/team/player-map-performance-card.tsx index cc2170e07..15b919d71 100644 --- a/src/components/stats/team/player-map-performance-card.tsx +++ b/src/components/stats/team/player-map-performance-card.tsx @@ -1,7 +1,7 @@ "use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import type { PlayerMapPerformanceMatrix } from "@/data/team-analytics-dto"; +import type { PlayerMapPerformanceMatrix } from "@/data/team/types"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { useState } from "react"; diff --git a/src/components/stats/team/simulator-tab.tsx b/src/components/stats/team/simulator-tab.tsx index b11f6773f..cf43b4783 100644 --- a/src/components/stats/team/simulator-tab.tsx +++ b/src/components/stats/team/simulator-tab.tsx @@ -29,7 +29,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { SimulatorContext } from "@/data/team-prediction-dto"; +import type { SimulatorContext } from "@/data/team/types"; import { determineRole } from "@/lib/player-table-data"; import { computePrediction, diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 51d8f7407..cd1e93e21 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,11 +1,11 @@ import { AppRuntime } from "@/data/runtime"; import { ScrimService } from "@/data/scrim"; import { resolveMapDataId } from "@/lib/map-data-resolver"; -import { Effect } from "effect"; import prisma from "@/lib/prisma"; import { groupKillsIntoFights, removeDuplicateRows, round } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { RoundEnd, UltimateCharged, UltimateStart } from "@prisma/client"; +import { Effect } from "effect"; export async function getAverageUltChargeTime(id: number, playerName: string) { const mapDataId = await resolveMapDataId(id); @@ -265,7 +265,11 @@ export async function calculateXFactor(mapId: number, playerName: string) { const resolvedMapId = await resolveMapDataId(mapId); // Get the player's role const playerStats = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(mapId, playerName) + ) + ) ); const mostPlayedHero = playerStats.sort( (a, b) => b.hero_time_played - a.hero_time_played @@ -317,7 +321,11 @@ export async function calculateXFactor(mapId: number, playerName: string) { }); const playerStatsByFinalRound = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(mapId, playerName) + ) + ) ); const team = playerStatsByFinalRound[0]?.player_team; diff --git a/src/lib/hero-pickrate-utils.ts b/src/lib/hero-pickrate-utils.ts index 8ed4dd969..d10e32c97 100644 --- a/src/lib/hero-pickrate-utils.ts +++ b/src/lib/hero-pickrate-utils.ts @@ -5,7 +5,7 @@ import type { HeroPickrateMatrix, HeroPickrateRawData, PlayerHeroData, -} from "@/data/team-analytics-dto"; +} from "@/data/team/types"; export function calculateHeroPickrateMatrix( rawData: HeroPickrateRawData, diff --git a/src/lib/parser.ts b/src/lib/parser.ts index fff385d3f..f50c5e556 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -1,5 +1,9 @@ import type { CreateScrimRequestData } from "@/app/api/scrim/create-scrim/route"; -import { calculateStats } from "@/lib/calculate-stats"; +// Dynamic import to avoid pulling server-only modules into the client bundle. +// calculateStats calls Prisma + Effect services that require Node.js. +function loadCalculateStats() { + return import("@/lib/calculate-stats").then((m) => m.calculateStats); +} import { headers } from "@/lib/headers"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; @@ -739,6 +743,7 @@ export async function calculateStatsForMap(mapDataId: number, scrimId: number) { scrimId ); + const calculateStats = await loadCalculateStats(); const stats = await calculateStats(mapDataId, player.player_name); const calculatedStatRecords = []; diff --git a/src/lib/prediction-engine.ts b/src/lib/prediction-engine.ts index 5f09075fb..2d820557c 100644 --- a/src/lib/prediction-engine.ts +++ b/src/lib/prediction-engine.ts @@ -1,4 +1,4 @@ -import type { SimulatorContext } from "@/data/team-prediction-dto"; +import type { SimulatorContext } from "@/data/team/types"; import type { RoleTrio } from "@/data/team/types"; import { mapNameToMapTypeMapping } from "@/types/map"; From a910f8cdfcbf1c22191f3172bf53ba971b5153fc Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:37:42 -0400 Subject: [PATCH 28/42] Split parser into client and server modules Extract client-safe parsing functions (parseData, parseDataFromTXT, parseDataFromXLSX, normalizeTeamData) to parser-client.ts. Client components import from parser-client.ts which has no server deps. parser.ts keeps server-only functions (createNewScrim, calculateStats) and gets "server-only" guard. runtime.ts restored with "server-only". --- src/components/dashboard/scrim-creator.tsx | 2 +- src/components/map/add-map.tsx | 2 +- .../match/tournament-add-map-card.tsx | 2 +- src/lib/parser-client.ts | 277 ++++++++++++++++++ src/lib/parser.ts | 9 +- 5 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 src/lib/parser-client.ts diff --git a/src/components/dashboard/scrim-creator.tsx b/src/components/dashboard/scrim-creator.tsx index 0eb5747ec..bc9d1e561 100644 --- a/src/components/dashboard/scrim-creator.tsx +++ b/src/components/dashboard/scrim-creator.tsx @@ -29,7 +29,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; -import { parseData } from "@/lib/parser"; +import { parseData } from "@/lib/parser-client"; import { cn, detectFileCorruption } from "@/lib/utils"; import { heroRoleMapping } from "@/types/heroes"; import type { ParserData } from "@/types/parser"; diff --git a/src/components/map/add-map.tsx b/src/components/map/add-map.tsx index f2046e639..60bdb9026 100644 --- a/src/components/map/add-map.tsx +++ b/src/components/map/add-map.tsx @@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Link } from "@/components/ui/link"; import { ClientOnly } from "@/lib/client-only"; -import { parseData } from "@/lib/parser"; +import { parseData } from "@/lib/parser-client"; import { cn, detectFileCorruption } from "@/lib/utils"; import { heroRoleMapping } from "@/types/heroes"; import type { ParserData } from "@/types/parser"; diff --git a/src/components/tournament/match/tournament-add-map-card.tsx b/src/components/tournament/match/tournament-add-map-card.tsx index 409a4d665..bb5617c75 100644 --- a/src/components/tournament/match/tournament-add-map-card.tsx +++ b/src/components/tournament/match/tournament-add-map-card.tsx @@ -20,7 +20,7 @@ import { SelectValue, } from "@/components/ui/select"; import { ClientOnly } from "@/lib/client-only"; -import { parseData } from "@/lib/parser"; +import { parseData } from "@/lib/parser-client"; import { cn, detectFileCorruption } from "@/lib/utils"; import { heroRoleMapping } from "@/types/heroes"; import type { ParserData } from "@/types/parser"; diff --git a/src/lib/parser-client.ts b/src/lib/parser-client.ts new file mode 100644 index 000000000..6199cd86a --- /dev/null +++ b/src/lib/parser-client.ts @@ -0,0 +1,277 @@ +import { headers } from "@/lib/headers"; +import type { ParserData } from "@/types/parser"; +import { type $Enums, MapType } from "@prisma/client"; +import * as XLSX from "xlsx"; + +const XLSX_FILE = + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + +const TXT_FILE = "text/plain"; + +const TEAM_NAME_FIELDS: Record = { + ability_1_used: [2], + ability_2_used: [2], + damage: [2, 5], + defensive_assist: [2], + dva_remech: [2], + echo_duplicate_end: [2], + echo_duplicate_start: [2], + healing: [2, 5], + hero_spawn: [2], + hero_swap: [2], + kill: [2, 5], + match_start: [4, 5], + objective_captured: [3], + offensive_assist: [2], + payload_progress: [3], + player_stat: [3], + point_progress: [3], + remech_charged: [2], + ultimate_charged: [2], + ultimate_end: [2], + ultimate_start: [2], + round_end: [3], + round_start: [3], +}; + +export const TEAM_POSITIONAL_SWAP_FIELDS: Record = { + match_start: [[4, 5]], + match_end: [[3, 4]], + objective_captured: [[5, 6]], + round_end: [ + [4, 5], + [7, 8], + ], + round_start: [[4, 5]], +}; + +export function normalizeTeamData( + data: ParserData, + newTeam1Name: string, + newTeam2Name: string | null, + userIsOriginalTeam2: boolean +): ParserData { + const origTeam1 = String(data.match_start[0][4]); + const origTeam2 = String(data.match_start[0][5]); + + const [fromTeam1, toTeam1] = userIsOriginalTeam2 + ? [origTeam2, newTeam1Name] + : [origTeam1, newTeam1Name]; + + const [fromTeam2, toTeam2] = userIsOriginalTeam2 + ? [origTeam1, newTeam2Name ?? origTeam1] + : [origTeam2, newTeam2Name ?? origTeam2]; + + const newData = structuredClone(data); + + for (const [eventType, rows] of Object.entries(newData)) { + const fieldPositions = TEAM_NAME_FIELDS[eventType]; + if (!fieldPositions) continue; + + for (const row of rows as unknown[][]) { + for (const pos of fieldPositions) { + if (String(row[pos]) === fromTeam1) { + row[pos] = toTeam1; + } else if (String(row[pos]) === fromTeam2) { + row[pos] = toTeam2; + } + } + } + + const swapPositions = TEAM_POSITIONAL_SWAP_FIELDS[eventType]; + if (swapPositions && userIsOriginalTeam2) { + for (const row of rows as unknown[][]) { + for (const [pos1, pos2] of swapPositions) { + const temp = row[pos1]; + row[pos1] = row[pos2]; + row[pos2] = temp; + } + } + } + } + + return newData; +} + +export async function parseData(file: File) { + switch (file.type) { + case XLSX_FILE: + return await parseDataFromXLSX(file); + case TXT_FILE: + return await parseDataFromTXT(file); + default: + throw new Error("Invalid file type"); + } +} + +function cleanInvalidLines(lines: string[][]): string[][] { + return lines + .filter((line) => { + if (line[0] === "mercy_rez") { + return !line.slice(1).some((field) => field === "" || field == null); + } + return true; + }) + .map((line) => { + return line.map((field) => { + if (field.includes("*")) { + return "0"; + } + return field; + }); + }); +} + +export function splitLinePreservingCoords(line: string): string[] { + const fields: string[] = []; + let current = ""; + let depth = 0; + for (const ch of line) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (ch === "," && depth === 0) { + fields.push(current); + current = ""; + } else { + current += ch; + } + } + fields.push(current); + return fields; +} + +export function parseCoordinate( + value: unknown +): { x: number; y: number; z: number } | null { + if (!value || typeof value !== "string") return null; + const match = value.trim().match(/^\(([^,]+),\s*([^,]+),\s*([^)]+)\)$/); + if (!match) return null; + const x = parseFloat(match[1]); + const y = parseFloat(match[2]); + const z = parseFloat(match[3]); + if (isNaN(x) || isNaN(y) || isNaN(z)) return null; + return { x, y, z }; +} + +export async function parseDataFromTXT(file: File) { + const fileContent = + process.env.NODE_ENV !== "test" + ? await file?.text() + : (file as unknown as string); + + const lines = fileContent + .split("\n") + .map((line) => splitLinePreservingCoords(line)); + + lines.forEach((line) => line.shift()); + + const cleanedLines = cleanInvalidLines(lines); + + const stringFieldIndexes: Record = { + kill: [3, 4, 6, 7, 8, 10, 11], + damage: [3, 4, 6, 7, 8, 10, 11], + healing: [3, 4, 6, 7, 8, 10], + }; + + function convertToNumberOrReplaceEmpty( + value: string, + eventType: string, + index: number + ) { + if (value === "") { + return null; + } + if ( + (eventType === "round_end" || eventType === "round_start") && + index === 3 + ) { + if (MapType.Control && value === "0") { + return 0; + } + return value; + } + if (isTeamNameField(eventType, index)) { + return value; + } + if (stringFieldIndexes[eventType]?.includes(index)) { + return value; + } + const parsedValue = parseFloat(value); + return isNaN(parsedValue) ? value : parsedValue; + } + + function isTeamNameField(eventType: string, index: number): boolean { + return TEAM_NAME_FIELDS[eventType]?.includes(index) || false; + } + + const categorizedData: Record = {}; + cleanedLines.forEach((line) => { + const eventType = line[0]; + if (headers[eventType]) { + if (!categorizedData[eventType]) { + categorizedData[eventType] = [headers[eventType]]; + } + const convertedLine = line.map((value, index) => + convertToNumberOrReplaceEmpty(value, eventType, index) + ); + categorizedData[eventType].push(convertedLine as never); + } + }); + + for (const kill of categorizedData["kill"]) { + if (kill[2] === "All Teams") { + kill[2] = kill[5]; + kill[3] = kill[6]; + kill[4] = kill[7]; + } + } + + const workbook = XLSX.utils.book_new(); + + const sortedEventTypes = Object.keys(categorizedData).sort(); + + sortedEventTypes.forEach((eventType) => { + const ws = XLSX.utils.aoa_to_sheet(categorizedData[eventType]); + XLSX.utils.book_append_sheet(workbook, ws, eventType); + }); + + const sheetName = workbook.SheetNames as $Enums.EventType[]; + + // oxlint-disable-next-line @typescript-eslint/consistent-type-assertions + const result = {} as ParserData; + + for (const sheet of sheetName) { + const ws = workbook.Sheets[sheet]; + const json = XLSX.utils.sheet_to_json(ws, { header: 1 }).slice(1); + + result[sheet] = json as never; + } + + return result; +} + +export async function parseDataFromXLSX(file: File) { + const reader = new FileReader(); + + const data = await new Promise((resolve, reject) => { + reader.onload = (e: ProgressEvent) => resolve(e.target?.result); + reader.onerror = () => reject(new Error("Failed to read the file.")); + reader.readAsBinaryString(file); + }); + + const workbook = XLSX.read(data, { type: "binary" }); + const sheetName = workbook.SheetNames as $Enums.EventType[]; + + // oxlint-disable-next-line @typescript-eslint/consistent-type-assertions + const result = {} as ParserData; + + for (const sheet of sheetName) { + const ws = workbook.Sheets[sheet]; + const json = XLSX.utils.sheet_to_json(ws, { header: 1 }).slice(1); + + // @ts-expect-error - Dynamic assignment + result[sheet] = json; + } + + return result; +} diff --git a/src/lib/parser.ts b/src/lib/parser.ts index f50c5e556..ba4c98739 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -1,9 +1,7 @@ +import "server-only"; + import type { CreateScrimRequestData } from "@/app/api/scrim/create-scrim/route"; -// Dynamic import to avoid pulling server-only modules into the client bundle. -// calculateStats calls Prisma + Effect services that require Node.js. -function loadCalculateStats() { - return import("@/lib/calculate-stats").then((m) => m.calculateStats); -} +import { calculateStats } from "@/lib/calculate-stats"; import { headers } from "@/lib/headers"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; @@ -743,7 +741,6 @@ export async function calculateStatsForMap(mapDataId: number, scrimId: number) { scrimId ); - const calculateStats = await loadCalculateStats(); const stats = await calculateStats(mapDataId, player.player_name); const calculatedStatRecords = []; From f26d10720fee441e3ab528483a981787ce0848cf Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:38:42 -0400 Subject: [PATCH 29/42] Update types and remove unused imports --- src/data/map/killfeed/service.ts | 15 +++----- src/data/team/analytics-service.ts | 55 ++++++++++++------------------ 2 files changed, 27 insertions(+), 43 deletions(-) diff --git a/src/data/map/killfeed/service.ts b/src/data/map/killfeed/service.ts index 8b7526ec7..554d133b6 100644 --- a/src/data/map/killfeed/service.ts +++ b/src/data/map/killfeed/service.ts @@ -2,7 +2,6 @@ import { EffectObservabilityLive } from "@/instrumentation"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; import { groupKillsIntoFights, type Fight } from "@/lib/utils"; -import type { Kill } from "@prisma/client"; import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; import { MapQueryError } from "../errors"; import { @@ -12,17 +11,12 @@ import { mapCacheMissTotal, mapCacheRequestTotal, } from "../metrics"; -import type { - FightUltimateData, - KillfeedDisplayOptions, - KillfeedEvent, - UltimateSpan, -} from "./types"; +import type { FightUltimateData, UltimateSpan } from "./types"; export { - hasAnyUltFeature, getEventTime, - mergeKillfeedEvents, + hasAnyUltFeature, isKillDuringUlt, + mergeKillfeedEvents, } from "./types"; const INSTANT_ULT_THRESHOLD = 1.0; @@ -128,7 +122,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.fight_count = 0; wideEvent.outcome = "success"; yield* Metric.increment(killfeedUltSpansQuerySuccessTotal); - const _empty: FightUltimateData[] = []; return _empty; + const _empty: FightUltimateData[] = []; + return _empty; } wideEvent.ult_start_count = ultimateStarts.length; diff --git a/src/data/team/analytics-service.ts b/src/data/team/analytics-service.ts index c1b710b9d..d64a6d59c 100644 --- a/src/data/team/analytics-service.ts +++ b/src/data/team/analytics-service.ts @@ -1,4 +1,3 @@ -import { EffectObservabilityLive } from "@/instrumentation"; import { determineRole } from "@/lib/player-table-data"; import prisma from "@/lib/prisma"; import { calculateWinner } from "@/lib/winrate"; @@ -28,6 +27,14 @@ import { TeamSharedDataService, TeamSharedDataServiceLive, } from "./shared-data-service"; +import type { + HeroPickrate, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerHeroData, + PlayerMapPerformance, + PlayerMapPerformanceMatrix, +} from "./types"; const analyticsQuerySuccessTotal = Metric.counter( "team.analytics.query.success", @@ -50,17 +57,9 @@ const analyticsQueryDuration = Metric.histogram( export type { HeroPickrate, - PlayerHeroData, HeroPickrateMatrix, HeroPickrateRawData, - PlayerMapPerformance, - PlayerMapPerformanceMatrix, -} from "./types"; -import type { - HeroPickrate, PlayerHeroData, - HeroPickrateMatrix, - HeroPickrateRawData, PlayerMapPerformance, PlayerMapPerformanceMatrix, } from "./types"; @@ -203,7 +202,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.player_count = 0; yield* Metric.increment(analyticsQuerySuccessTotal); - return { players: [], allHeroes: [] } as HeroPickrateMatrix; + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; } const dateFilter = { @@ -244,7 +244,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.player_count = 0; yield* Metric.increment(analyticsQuerySuccessTotal); - return { players: [], allHeroes: [] } as HeroPickrateMatrix; + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; } const mapDataIds = mapDataRecords.map((md) => md.id); @@ -298,7 +299,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.player_count = 0; yield* Metric.increment(analyticsQuerySuccessTotal); - return { players: [], allHeroes: [] } as HeroPickrateMatrix; + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; } const result = buildPlayerHeroData( @@ -363,7 +365,7 @@ export const make = Effect.gen(function* () { players: [], maps: [], performance: [], - } as PlayerMapPerformanceMatrix; + } satisfies PlayerMapPerformanceMatrix; } const { @@ -476,8 +478,7 @@ export const make = Effect.gen(function* () { for (const [mapName, data] of mapsData.entries()) { uniqueMaps.add(mapName); const gamesPlayed = data.wins + data.losses; - const winrate = - gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; + const winrate = gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; performance.push({ playerName, @@ -515,12 +516,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome ??= "interrupted"; const log = wideEvent.outcome === "error" - ? Effect.logError( - "team.analytics.getPlayerMapPerformanceMatrix" - ) - : Effect.logInfo( - "team.analytics.getPlayerMapPerformanceMatrix" - ); + ? Effect.logError("team.analytics.getPlayerMapPerformanceMatrix") + : Effect.logInfo("team.analytics.getPlayerMapPerformanceMatrix"); return log.pipe( Effect.annotateLogs(wideEvent), Effect.andThen(analyticsQueryDuration(Effect.succeed(durationMs))) @@ -553,7 +550,7 @@ export const make = Effect.gen(function* () { teamRoster: [], mapDataRecords: [], allPlayerStats: [], - } as HeroPickrateRawData; + } satisfies HeroPickrateRawData; } const scrimWhereClause: Record = { @@ -615,7 +612,7 @@ export const make = Effect.gen(function* () { teamRoster, mapDataRecords: [], allPlayerStats: [], - } as HeroPickrateRawData; + } satisfies HeroPickrateRawData; } const mapDataIds = mapDataRecords.map((md) => md.id); @@ -683,11 +680,7 @@ export const make = Effect.gen(function* () { return `${teamId}:${JSON.stringify(dateRange ?? {})}`; } - function pickrateCacheKeyOf( - teamId: number, - dateFrom?: Date, - dateTo?: Date - ) { + function pickrateCacheKeyOf(teamId: number, dateFrom?: Date, dateTo?: Date) { return `${teamId}:${dateFrom?.toISOString() ?? ""}:${dateTo?.toISOString() ?? ""}`; } @@ -740,11 +733,7 @@ export const make = Effect.gen(function* () { }); return { - getHeroPickrateMatrix: ( - teamId: number, - dateFrom?: Date, - dateTo?: Date - ) => + getHeroPickrateMatrix: (teamId: number, dateFrom?: Date, dateTo?: Date) => pickrateCache .get(pickrateCacheKeyOf(teamId, dateFrom, dateTo)) .pipe(Effect.tap(() => Metric.increment(teamCacheRequestTotal))), From 76d444a30952211bf55af47dca0bab3632138d24 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:39:02 -0400 Subject: [PATCH 30/42] Reformat --- .zed/settings.json | 102 +++++++++--------- src/app/[team]/scrim/[scrimId]/edit/page.tsx | 8 +- src/app/api/(email)/send-team-invite/route.ts | 4 +- src/app/api/bot/profile/route.ts | 5 +- .../tournament/[tournamentId]/route.ts | 4 +- src/app/api/compare/map-groups/[id]/route.ts | 4 +- src/app/api/notifications/route.ts | 8 +- src/app/api/player/stats/route.ts | 31 ++++-- src/app/api/scrim/create-scrim/route.ts | 4 +- src/app/api/scrim/remove-map/route.ts | 6 +- src/app/api/scrim/remove-scrim/route.ts | 6 +- src/app/api/team/remove-team/route.ts | 6 +- src/app/api/team/transfer-ownership/route.ts | 6 +- src/app/api/user/app-settings/route.ts | 8 +- src/app/dashboard/page.tsx | 4 +- src/app/layout.tsx | 4 +- src/app/profile/[playerName]/page.tsx | 35 ++++-- src/app/scouting/team/[teamAbbr]/page.tsx | 55 ++++++---- src/app/stats/[playerName]/page.tsx | 31 ++++-- src/app/stats/team/[teamId]/page.tsx | 37 ++++--- src/app/team/[teamId]/targets/page.tsx | 4 +- src/app/team/page.tsx | 4 +- src/app/tournaments/[id]/page.tsx | 4 +- .../data-labeling/match-labeling-view.tsx | 5 +- src/components/map/default-overview.tsx | 12 +-- src/components/map/heatmap/heatmap-tab.tsx | 4 +- src/components/map/killfeed.tsx | 4 +- src/components/map/replay/replay-tab.tsx | 4 +- .../map/tempo/tempo-chart-server.tsx | 4 +- .../scouting/player-kill-analysis.tsx | 5 +- src/components/scrim/swap-win-rate-chart.tsx | 5 +- .../stats/team/hero-ban-impact-card.tsx | 5 +- .../stats/team/hero-our-bans-card.tsx | 5 +- .../stats/team/matchup-winrate-tab.tsx | 5 +- src/components/stats/team/swap-pairs-card.tsx | 5 +- src/data/__tests__/migration-parity.test.ts | 5 +- src/data/hero/service.ts | 9 +- src/data/intelligence/hero-ban-service.ts | 8 +- src/data/intelligence/map-service.ts | 8 +- src/data/player/targets-service.ts | 6 +- src/data/scrim/ability-timing-service.ts | 12 ++- src/data/scrim/opponent-service.ts | 4 +- src/data/scrim/overview-service.ts | 4 +- src/data/scrim/scrim-service.ts | 29 +++-- src/data/scrim/types.ts | 5 +- src/data/team/ability-impact-service.ts | 6 +- src/data/team/prediction-service.ts | 14 +-- src/data/team/shared-data-service.ts | 14 +-- src/data/team/stats-service.ts | 6 +- src/data/team/trends-service.ts | 11 +- src/lib/ai/tools.ts | 46 ++++---- src/lib/auth.ts | 4 +- src/lib/calculate-stats.ts | 12 ++- src/lib/mvp-score.ts | 6 +- 54 files changed, 380 insertions(+), 272 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index c6fd40017..1f87157fc 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -7,128 +7,128 @@ "oxfmt": { "initialization_options": { "settings": { - "fmt.configPath": null, - }, - }, - }, + "fmt.configPath": null + } + } + } }, "languages": { "CSS": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "HTML": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "JavaScript": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "JSON": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "JSONC": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "Markdown": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "TypeScript": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "TSX": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], + "name": "oxfmt" + } + } + ] }, "YAML": { "format_on_save": "on", "prettier": { - "allowed": false, + "allowed": false }, "formatter": [ { "language_server": { - "name": "oxfmt", - }, - }, - ], - }, - }, + "name": "oxfmt" + } + } + ] + } + } } diff --git a/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 800687c76..7eaa7cd14 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -19,7 +19,9 @@ export default async function EditScrimPage( ) { const params = await props.params; const scrim = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(params.scrimId)))) + ScrimService.pipe( + Effect.flatMap((svc) => svc.getScrim(parseInt(params.scrimId))) + ) ); const session = await auth(); const t = await getTranslations("scrimPage.editScrim"); @@ -32,9 +34,7 @@ export default async function EditScrimPage( [ AppRuntime.runPromise( UserService.pipe( - Effect.flatMap((svc) => - svc.getTeamsWithPerms(session?.user?.email) - ) + Effect.flatMap((svc) => svc.getTeamsWithPerms(session?.user?.email)) ) ), AppRuntime.runPromise( diff --git a/src/app/api/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts index e8596336a..8eea6b49f 100644 --- a/src/app/api/(email)/send-team-invite/route.ts +++ b/src/app/api/(email)/send-team-invite/route.ts @@ -38,7 +38,9 @@ export async function POST(req: NextRequest) { if (!teamInviteToken) return new Response("Token not found", { status: 404 }); const inviter = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(teamInviteToken?.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(teamInviteToken?.email)) + ) ); if (!inviter) return new Response("Inviter not found", { status: 404 }); diff --git a/src/app/api/bot/profile/route.ts b/src/app/api/bot/profile/route.ts index 013ecf271..f145e92c2 100644 --- a/src/app/api/bot/profile/route.ts +++ b/src/app/api/bot/profile/route.ts @@ -1,4 +1,7 @@ -import { aggregatePlayerStats, calculateTrends } from "@/data/comparison/computation"; +import { + aggregatePlayerStats, + calculateTrends, +} from "@/data/comparison/computation"; import { removeDuplicateRows } from "@/lib/utils"; import { authenticateBotSecret } from "@/lib/bot-auth"; import { Logger } from "@/lib/logger"; diff --git a/src/app/api/broadcast/tournament/[tournamentId]/route.ts b/src/app/api/broadcast/tournament/[tournamentId]/route.ts index 0c0f301fe..61b4b9382 100644 --- a/src/app/api/broadcast/tournament/[tournamentId]/route.ts +++ b/src/app/api/broadcast/tournament/[tournamentId]/route.ts @@ -49,9 +49,7 @@ export async function GET( const data = await AppRuntime.runPromise( BroadcastService.pipe( - Effect.flatMap((svc) => - svc.getTournamentBroadcastData(tournamentId) - ) + Effect.flatMap((svc) => svc.getTournamentBroadcastData(tournamentId)) ) ); diff --git a/src/app/api/compare/map-groups/[id]/route.ts b/src/app/api/compare/map-groups/[id]/route.ts index 5aa90cae0..68e7958a2 100644 --- a/src/app/api/compare/map-groups/[id]/route.ts +++ b/src/app/api/compare/map-groups/[id]/route.ts @@ -332,9 +332,7 @@ export async function DELETE( }; await AppRuntime.runPromise( - MapGroupService.pipe( - Effect.flatMap((svc) => svc.deleteMapGroup(groupId)) - ) + MapGroupService.pipe(Effect.flatMap((svc) => svc.deleteMapGroup(groupId))) ); wideEvent.status_code = 200; diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index c7c258f82..9e7151f5b 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -78,7 +78,9 @@ export async function POST(request: NextRequest) { if (body.data.userId) { // Explicit user ID provided - verify session user has permission const sessionUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session.user.email)) + ) ); if (!sessionUser) unauthorized(); @@ -88,7 +90,9 @@ export async function POST(request: NextRequest) { } else { // Fallback to session user (backward compatibility) const sessionUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session.user.email)) + ) ); if (!sessionUser) unauthorized(); targetUserId = sessionUser.id; diff --git a/src/app/api/player/stats/route.ts b/src/app/api/player/stats/route.ts index 8c933a7f9..769b32329 100644 --- a/src/app/api/player/stats/route.ts +++ b/src/app/api/player/stats/route.ts @@ -102,12 +102,31 @@ export async function GET(request: NextRequest) { try { const { allPlayerStats, allPlayerKills, mapWinrates, allPlayerDeaths } = await AppRuntime.runPromise( - Effect.all({ - allPlayerStats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), - allPlayerKills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), - mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), - allPlayerDeaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), - }, { concurrency: "unbounded" }) + Effect.all( + { + allPlayerStats: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllStatsForPlayer(permittedScrimIds, name) + ) + ), + allPlayerKills: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllKillsForPlayer(permittedScrimIds, name) + ) + ), + mapWinrates: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllMapWinratesForPlayer(permittedScrimIds, name) + ) + ), + allPlayerDeaths: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllDeathsForPlayer(permittedScrimIds, name) + ) + ), + }, + { concurrency: "unbounded" } + ) ); return NextResponse.json({ diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 85d920342..7e3149939 100644 --- a/src/app/api/scrim/create-scrim/route.ts +++ b/src/app/api/scrim/create-scrim/route.ts @@ -81,7 +81,9 @@ export async function POST(request: NextRequest) { event.user_agent = ua.ua; const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session.user.email)) + ) ); event.user_id = user?.id; event.billing_plan = user?.billingPlan; diff --git a/src/app/api/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts index 73473484a..832c17613 100644 --- a/src/app/api/scrim/remove-map/route.ts +++ b/src/app/api/scrim/remove-map/route.ts @@ -27,7 +27,11 @@ export async function POST(req: NextRequest) { } const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + UserService.pipe( + Effect.flatMap((svc) => + svc.getUser(session?.user?.email ?? "lucas@lux.dev") + ) + ) ); if (!user) return new Response("User not found", { status: 404 }); diff --git a/src/app/api/scrim/remove-scrim/route.ts b/src/app/api/scrim/remove-scrim/route.ts index e07a53deb..325890500 100644 --- a/src/app/api/scrim/remove-scrim/route.ts +++ b/src/app/api/scrim/remove-scrim/route.ts @@ -27,7 +27,11 @@ export async function POST(req: NextRequest) { } const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + UserService.pipe( + Effect.flatMap((svc) => + svc.getUser(session?.user?.email ?? "lucas@lux.dev") + ) + ) ); if (!user) return new Response("User not found", { status: 404 }); diff --git a/src/app/api/team/remove-team/route.ts b/src/app/api/team/remove-team/route.ts index ef63ccd1e..95a1708c6 100644 --- a/src/app/api/team/remove-team/route.ts +++ b/src/app/api/team/remove-team/route.ts @@ -28,7 +28,11 @@ export async function POST(req: NextRequest) { } const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + UserService.pipe( + Effect.flatMap((svc) => + svc.getUser(session?.user?.email ?? "lucas@lux.dev") + ) + ) ); if (!user) return new Response("User not found", { status: 404 }); diff --git a/src/app/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts index 79cb7690a..db62776a8 100644 --- a/src/app/api/team/transfer-ownership/route.ts +++ b/src/app/api/team/transfer-ownership/route.ts @@ -30,7 +30,11 @@ export async function POST(req: NextRequest) { } const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email ?? "lucas@lux.dev"))) + UserService.pipe( + Effect.flatMap((svc) => + svc.getUser(session?.user?.email ?? "lucas@lux.dev") + ) + ) ); if (!user) return new Response("User not found", { status: 404 }); diff --git a/src/app/api/user/app-settings/route.ts b/src/app/api/user/app-settings/route.ts index cf8ae2a4f..5bd091d4d 100644 --- a/src/app/api/user/app-settings/route.ts +++ b/src/app/api/user/app-settings/route.ts @@ -28,13 +28,17 @@ export async function GET() { try { let appSettings = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getAppSettings(session.user.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getAppSettings(session.user.email)) + ) ); // If no app settings exist, create default ones if (!appSettings) { const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + UserService.pipe( + Effect.flatMap((svc) => svc.getUser(session.user.email)) + ) ); if (!user) { Logger.error("User not found when creating app settings"); diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a9d3a03e6..236540882 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -51,9 +51,7 @@ export default async function DashboardPage() { ]); const userData = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session?.user?.email)) - ) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); const isAdmin = userData?.role === $Enums.UserRole.ADMIN; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1c86cb571..15ce130cd 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -72,9 +72,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { if (session) { user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session.user.email)) - ) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) ); } diff --git a/src/app/profile/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx index e2569dc2b..2c7b0eaf5 100644 --- a/src/app/profile/[playerName]/page.tsx +++ b/src/app/profile/[playerName]/page.tsx @@ -270,12 +270,31 @@ export default async function ProfilePage( const permittedScrimIds = data[permitted].map((scrim) => scrim.id); const { stats, kills, deaths, mapWinrates } = await AppRuntime.runPromise( - Effect.all({ - stats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), - kills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), - deaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), - mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), - }, { concurrency: "unbounded" }) + Effect.all( + { + stats: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllStatsForPlayer(permittedScrimIds, name) + ) + ), + kills: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllKillsForPlayer(permittedScrimIds, name) + ) + ), + deaths: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllDeathsForPlayer(permittedScrimIds, name) + ) + ), + mapWinrates: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllMapWinratesForPlayer(permittedScrimIds, name) + ) + ), + }, + { concurrency: "unbounded" } + ) ); // Calculate max time for bar chart scaling @@ -286,9 +305,7 @@ export default async function ProfilePage( // Targets tab: visible on own profile or for site admins const session = await auth(); const sessionUser = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session?.user?.email)) - ) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); const isOwnProfile = user && session?.user?.email && session.user.email === user.email; diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index b80924c4c..47e4fd015 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -8,7 +8,10 @@ import { ScoutForTeamPicker } from "@/components/scouting/scout-for-team-picker" import { ScoutingReport } from "@/components/scouting/scouting-report"; import { TeamOverviewEnhanced } from "@/components/scouting/team-overview-enhanced"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { HeroBanIntelligenceService, MapIntelligenceService } from "@/data/intelligence"; +import { + HeroBanIntelligenceService, + MapIntelligenceService, +} from "@/data/intelligence"; import { IntelligenceService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; import { OpponentStrengthService, ScoutingService } from "@/data/scouting"; @@ -116,30 +119,36 @@ export default async function ScoutingTeamPage( const [mapIntelligence, banIntelligence, playerIntelligence] = await AppRuntime.runPromise( - Effect.all({ - mapIntelligence: MapIntelligenceService.pipe( - Effect.flatMap((svc) => - svc.getMapIntelligence(teamAbbr, userTeamId, dataAvailability) - ) - ), - banIntelligence: HeroBanIntelligenceService.pipe( - Effect.flatMap((svc) => - svc.getHeroBanIntelligence(teamAbbr, userTeamId, dataAvailability) - ) - ), - playerIntelligence: userTeamId - ? IntelligenceService.pipe( - Effect.flatMap((svc) => - svc.getPlayerIntelligence( - userTeamId, - teamAbbr, - dataAvailability + Effect.all( + { + mapIntelligence: MapIntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getMapIntelligence(teamAbbr, userTeamId, dataAvailability) + ) + ), + banIntelligence: HeroBanIntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getHeroBanIntelligence(teamAbbr, userTeamId, dataAvailability) + ) + ), + playerIntelligence: userTeamId + ? IntelligenceService.pipe( + Effect.flatMap((svc) => + svc.getPlayerIntelligence( + userTeamId, + teamAbbr, + dataAvailability + ) ) ) - ) - : Effect.succeed(null), - }, { concurrency: "unbounded" }) - ).then((r) => [r.mapIntelligence, r.banIntelligence, r.playerIntelligence] as const); + : Effect.succeed(null), + }, + { concurrency: "unbounded" } + ) + ).then( + (r) => + [r.mapIntelligence, r.banIntelligence, r.playerIntelligence] as const + ); const insightReport = generateInsights({ mapIntelligence, diff --git a/src/app/stats/[playerName]/page.tsx b/src/app/stats/[playerName]/page.tsx index 18a525c6f..8d11c52be 100644 --- a/src/app/stats/[playerName]/page.tsx +++ b/src/app/stats/[playerName]/page.tsx @@ -146,12 +146,31 @@ export default async function PlayerStats( try { const result = await AppRuntime.runPromise( - Effect.all({ - allPlayerStats: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllStatsForPlayer(permittedScrimIds, name))), - allPlayerKills: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllKillsForPlayer(permittedScrimIds, name))), - mapWinrates: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer(permittedScrimIds, name))), - allPlayerDeaths: ScrimService.pipe(Effect.flatMap((svc) => svc.getAllDeathsForPlayer(permittedScrimIds, name))), - }, { concurrency: "unbounded" }) + Effect.all( + { + allPlayerStats: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllStatsForPlayer(permittedScrimIds, name) + ) + ), + allPlayerKills: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllKillsForPlayer(permittedScrimIds, name) + ) + ), + mapWinrates: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllMapWinratesForPlayer(permittedScrimIds, name) + ) + ), + allPlayerDeaths: ScrimService.pipe( + Effect.flatMap((svc) => + svc.getAllDeathsForPlayer(permittedScrimIds, name) + ) + ), + }, + { concurrency: "unbounded" } + ) ); allPlayerStats = result.allPlayerStats; allPlayerKills = result.allPlayerKills; diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx index 18077fdb1..ce12adf6d 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -32,10 +32,7 @@ import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-car import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights"; import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { - TeamAnalyticsService, - TeamPredictionService, -} from "@/data/team"; +import { TeamAnalyticsService, TeamPredictionService } from "@/data/team"; import { type TeamDateRange, TeamStatsService, @@ -213,7 +210,9 @@ export default async function TeamStatsPage( Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) ), top5Maps: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTop5MapsByPlaytime(teamId, dateRange)) + Effect.flatMap((svc) => + svc.getTop5MapsByPlaytime(teamId, dateRange) + ) ), allMapsPlaytime: TeamStatsService.pipe( Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) @@ -268,9 +267,7 @@ export default async function TeamStatsPage( Effect.flatMap((svc) => svc.getTeamUltStats(teamId, dateRange)) ), heroSwapStats: TeamHeroSwapService.pipe( - Effect.flatMap((svc) => - svc.getTeamHeroSwapStats(teamId, dateRange) - ) + Effect.flatMap((svc) => svc.getTeamHeroSwapStats(teamId, dateRange)) ), banImpactAnalysis: TeamBanImpactService.pipe( Effect.flatMap((svc) => @@ -281,9 +278,7 @@ export default async function TeamStatsPage( Effect.flatMap((svc) => svc.getTeamUltImpact(teamId, dateRange)) ), abilityImpactAnalysis: TeamAbilityImpactService.pipe( - Effect.flatMap((svc) => - svc.getTeamAbilityImpact(teamId, dateRange) - ) + Effect.flatMap((svc) => svc.getTeamAbilityImpact(teamId, dateRange)) ), matchupWinrateData: TeamMatchupService.pipe( Effect.flatMap((svc) => @@ -306,11 +301,25 @@ export default async function TeamStatsPage( }, }), getMapNames(), - AppRuntime.runPromise(TeamAnalyticsService.pipe(Effect.flatMap((svc) => svc.getPlayerMapPerformanceMatrix(teamId, dateRange)))), - AppRuntime.runPromise(TeamPredictionService.pipe(Effect.flatMap((svc) => svc.getSimulatorContext(teamId, dateRange)))), + AppRuntime.runPromise( + TeamAnalyticsService.pipe( + Effect.flatMap((svc) => + svc.getPlayerMapPerformanceMatrix(teamId, dateRange) + ) + ) + ), + AppRuntime.runPromise( + TeamPredictionService.pipe( + Effect.flatMap((svc) => svc.getSimulatorContext(teamId, dateRange)) + ) + ), simulationTool(), ultimateImpactTool(), - AppRuntime.runPromise(TeamAnalyticsService.pipe(Effect.flatMap((svc) => svc.getHeroPickrateRawData(teamId, dateRange)))), + AppRuntime.runPromise( + TeamAnalyticsService.pipe( + Effect.flatMap((svc) => svc.getHeroPickrateRawData(teamId, dateRange)) + ) + ), ]); const heroPickrateMatrix = calculateHeroPickrateMatrix(heroPickrateRawData); diff --git a/src/app/team/[teamId]/targets/page.tsx b/src/app/team/[teamId]/targets/page.tsx index c00687928..47392450b 100644 --- a/src/app/team/[teamId]/targets/page.tsx +++ b/src/app/team/[teamId]/targets/page.tsx @@ -102,9 +102,7 @@ export default async function TeamTargetsPage(props: Props) { }, }), AppRuntime.runPromise( - TargetsService.pipe( - Effect.flatMap((svc) => svc.getTeamTargets(teamId)) - ) + TargetsService.pipe(Effect.flatMap((svc) => svc.getTeamTargets(teamId))) ), ]); diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx index c1cca3338..48c8bd8ce 100644 --- a/src/app/team/page.tsx +++ b/src/app/team/page.tsx @@ -46,9 +46,7 @@ export default async function TeamPage() { const session = await auth(); const userData = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session?.user?.email)) - ) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); const userTeams = await prisma.team.findMany({ diff --git a/src/app/tournaments/[id]/page.tsx b/src/app/tournaments/[id]/page.tsx index c8f21d920..6675720f0 100644 --- a/src/app/tournaments/[id]/page.tsx +++ b/src/app/tournaments/[id]/page.tsx @@ -46,9 +46,7 @@ export default async function TournamentDetailPage(props: { if (data.format === "ROUND_ROBIN_SE") { rrStandings = await AppRuntime.runPromise( - TournamentService.pipe( - Effect.flatMap((svc) => svc.getRRStandings(id)) - ) + TournamentService.pipe(Effect.flatMap((svc) => svc.getRRStandings(id))) ); const session = await auth(); diff --git a/src/components/data-labeling/match-labeling-view.tsx b/src/components/data-labeling/match-labeling-view.tsx index 94092dc78..338737a4f 100644 --- a/src/components/data-labeling/match-labeling-view.tsx +++ b/src/components/data-labeling/match-labeling-view.tsx @@ -7,10 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import type { - MatchForLabeling, - MatchMapForLabeling, -} from "@/data/admin/types"; +import type { MatchForLabeling, MatchMapForLabeling } from "@/data/admin/types"; import { toHero } from "@/lib/utils"; import { heroRoleMapping, type HeroName } from "@/types/heroes"; import { YouTubeEmbed } from "@next/third-parties/google"; diff --git a/src/components/map/default-overview.tsx b/src/components/map/default-overview.tsx index 4e7ce0b8d..b61d438ab 100644 --- a/src/components/map/default-overview.tsx +++ b/src/components/map/default-overview.tsx @@ -7,11 +7,11 @@ import { } from "@/data/map"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; +import { ScrimAbilityTimingService, ScrimService } from "@/data/scrim"; import { - ScrimAbilityTimingService, - ScrimService, -} from "@/data/scrim"; -import { assignPlayersToSubroles, buildPlayerUltComparisons } from "@/data/scrim/ult-helpers"; + assignPlayersToSubroles, + buildPlayerUltComparisons, +} from "@/data/scrim/ult-helpers"; import type { PlayerUltSummary, UltEfficiency } from "@/data/scrim/types"; import { positionalData } from "@/lib/flags"; import { @@ -69,9 +69,7 @@ export async function DefaultOverview({ }), prisma.matchStart.findFirst({ where: { MapDataId: mapDataId } }), AppRuntime.runPromise( - ScrimService.pipe( - Effect.flatMap((svc) => svc.getFinalRoundStats(id)) - ) + ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStats(id))) ), prisma.playerStat.findMany({ where: { MapDataId: mapDataId } }), groupKillsIntoFights(id), diff --git a/src/components/map/heatmap/heatmap-tab.tsx b/src/components/map/heatmap/heatmap-tab.tsx index d94ebf601..84ce1c1b8 100644 --- a/src/components/map/heatmap/heatmap-tab.tsx +++ b/src/components/map/heatmap/heatmap-tab.tsx @@ -8,9 +8,7 @@ import { HeatmapControlTabs } from "./heatmap-control-tabs"; export async function HeatmapTab({ id }: { id: number }) { const [data, t] = await Promise.all([ AppRuntime.runPromise( - HeatmapService.pipe( - Effect.flatMap((svc) => svc.getHeatmapData(id)) - ) + HeatmapService.pipe(Effect.flatMap((svc) => svc.getHeatmapData(id))) ), getTranslations("mapPage.heatmap"), ]); diff --git a/src/components/map/killfeed.tsx b/src/components/map/killfeed.tsx index 9f0e6952d..5e640e9c6 100644 --- a/src/components/map/killfeed.tsx +++ b/src/components/map/killfeed.tsx @@ -48,9 +48,7 @@ export async function Killfeed({ prisma.matchStart.findFirst({ where: { MapDataId: mapDataId } }), groupKillsIntoFights(id), AppRuntime.runPromise( - KillfeedService.pipe( - Effect.flatMap((svc) => svc.getUltimateSpans(id)) - ) + KillfeedService.pipe(Effect.flatMap((svc) => svc.getUltimateSpans(id))) ), positionalData(), coachingCanvas(), diff --git a/src/components/map/replay/replay-tab.tsx b/src/components/map/replay/replay-tab.tsx index bfab0fd63..1ffd20c35 100644 --- a/src/components/map/replay/replay-tab.tsx +++ b/src/components/map/replay/replay-tab.tsx @@ -7,9 +7,7 @@ import { ReplayViewer } from "./replay-viewer"; export async function ReplayTab({ id }: { id: number }) { const [data, t] = await Promise.all([ AppRuntime.runPromise( - ReplayService.pipe( - Effect.flatMap((svc) => svc.getReplayData(id)) - ) + ReplayService.pipe(Effect.flatMap((svc) => svc.getReplayData(id))) ), getTranslations("mapPage.replay"), ]); diff --git a/src/components/map/tempo/tempo-chart-server.tsx b/src/components/map/tempo/tempo-chart-server.tsx index 3283f4a13..881f303a7 100644 --- a/src/components/map/tempo/tempo-chart-server.tsx +++ b/src/components/map/tempo/tempo-chart-server.tsx @@ -17,9 +17,7 @@ export async function TempoChartServer({ }: TempoChartServerProps) { const [data, t] = await Promise.all([ AppRuntime.runPromise( - TempoService.pipe( - Effect.flatMap((svc) => svc.getTempoChartData(id)) - ) + TempoService.pipe(Effect.flatMap((svc) => svc.getTempoChartData(id))) ), getTranslations("mapPage.events.tempo"), ]); diff --git a/src/components/scouting/player-kill-analysis.tsx b/src/components/scouting/player-kill-analysis.tsx index bfb7aa97b..2d7b5eaa4 100644 --- a/src/components/scouting/player-kill-analysis.tsx +++ b/src/components/scouting/player-kill-analysis.tsx @@ -14,10 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { - KillPatterns, - RoleDistributionEntry, -} from "@/data/player/types"; +import type { KillPatterns, RoleDistributionEntry } from "@/data/player/types"; import { toHero } from "@/lib/utils"; import { useTranslations } from "next-intl"; import Image from "next/image"; diff --git a/src/components/scrim/swap-win-rate-chart.tsx b/src/components/scrim/swap-win-rate-chart.tsx index 47bd28664..02affc55d 100644 --- a/src/components/scrim/swap-win-rate-chart.tsx +++ b/src/components/scrim/swap-win-rate-chart.tsx @@ -1,9 +1,6 @@ "use client"; -import type { - SwapTimingOutcome, - SwapWinrateBucket, -} from "@/data/team/types"; +import type { SwapTimingOutcome, SwapWinrateBucket } from "@/data/team/types"; import { Bar, BarChart, diff --git a/src/components/stats/team/hero-ban-impact-card.tsx b/src/components/stats/team/hero-ban-impact-card.tsx index d9b5b540d..d16b15aac 100644 --- a/src/components/stats/team/hero-ban-impact-card.tsx +++ b/src/components/stats/team/hero-ban-impact-card.tsx @@ -14,10 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { - CombinedBanAnalysis, - HeroBanImpact, -} from "@/data/team/types"; +import type { CombinedBanAnalysis, HeroBanImpact } from "@/data/team/types"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import { AlertTriangle, ShieldOff } from "lucide-react"; import Image from "next/image"; diff --git a/src/components/stats/team/hero-our-bans-card.tsx b/src/components/stats/team/hero-our-bans-card.tsx index f394d4a63..a0509645a 100644 --- a/src/components/stats/team/hero-our-bans-card.tsx +++ b/src/components/stats/team/hero-our-bans-card.tsx @@ -14,10 +14,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import type { - OurBanImpact, - TeamOurBanAnalysis, -} from "@/data/team/types"; +import type { OurBanImpact, TeamOurBanAnalysis } from "@/data/team/types"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import { Swords, TrendingUp } from "lucide-react"; import Image from "next/image"; diff --git a/src/components/stats/team/matchup-winrate-tab.tsx b/src/components/stats/team/matchup-winrate-tab.tsx index 4283ab3bc..9c7b0e3ed 100644 --- a/src/components/stats/team/matchup-winrate-tab.tsx +++ b/src/components/stats/team/matchup-winrate-tab.tsx @@ -21,10 +21,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { - MatchupMapResult, - MatchupWinrateData, -} from "@/data/team/types"; +import type { MatchupMapResult, MatchupWinrateData } from "@/data/team/types"; import { determineRole } from "@/lib/player-table-data"; import { cn, toHero, useHeroNames } from "@/lib/utils"; import type { HeroName } from "@/types/heroes"; diff --git a/src/components/stats/team/swap-pairs-card.tsx b/src/components/stats/team/swap-pairs-card.tsx index 2d32939ea..8a4b242fb 100644 --- a/src/components/stats/team/swap-pairs-card.tsx +++ b/src/components/stats/team/swap-pairs-card.tsx @@ -3,10 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { ChartConfig } from "@/components/ui/chart"; import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; -import type { - SwapTimingBucket, - TeamHeroSwapStats, -} from "@/data/team/types"; +import type { SwapTimingBucket, TeamHeroSwapStats } from "@/data/team/types"; import { cn, toHero } from "@/lib/utils"; import { useTranslations } from "next-intl"; import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"; diff --git a/src/data/__tests__/migration-parity.test.ts b/src/data/__tests__/migration-parity.test.ts index 28da2b797..b533706ac 100644 --- a/src/data/__tests__/migration-parity.test.ts +++ b/src/data/__tests__/migration-parity.test.ts @@ -122,10 +122,7 @@ describe("User service parity", () => { // --------------------------------------------------------------------------- // Tournament domain // --------------------------------------------------------------------------- -import { - getTournament, - getUserTournaments, -} from "@/data/tournament-dto"; +import { getTournament, getUserTournaments } from "@/data/tournament-dto"; import { TournamentService, TournamentServiceLive, diff --git a/src/data/hero/service.ts b/src/data/hero/service.ts index 934f9e346..12f1e6d62 100644 --- a/src/data/hero/service.ts +++ b/src/data/hero/service.ts @@ -97,7 +97,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.resultCount = 0; yield* Metric.increment(heroStatsQuerySuccessTotal); - const _empty: PlayerStat[] = []; return _empty; + const _empty: PlayerStat[] = []; + return _empty; } const rows = yield* Effect.tryPromise({ @@ -194,7 +195,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.resultCount = 0; yield* Metric.increment(heroKillsQuerySuccessTotal); - const _empty: Kill[] = []; return _empty; + const _empty: Kill[] = []; + return _empty; } const kills = yield* Effect.tryPromise({ @@ -274,7 +276,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.resultCount = 0; yield* Metric.increment(heroDeathsQuerySuccessTotal); - const _empty: Kill[] = []; return _empty; + const _empty: Kill[] = []; + return _empty; } const deaths = yield* Effect.tryPromise({ diff --git a/src/data/intelligence/hero-ban-service.ts b/src/data/intelligence/hero-ban-service.ts index 1ee9da372..26b714428 100644 --- a/src/data/intelligence/hero-ban-service.ts +++ b/src/data/intelligence/hero-ban-service.ts @@ -503,7 +503,13 @@ export const make = Effect.gen(function* () { opponentAbbr: string, userTeamId: number | null, profile?: DataAvailabilityProfile - ) { return JSON.stringify({ opponentAbbr, userTeamId, profile: profile ?? null }); } + ) { + return JSON.stringify({ + opponentAbbr, + userTeamId, + profile: profile ?? null, + }); + } const heroBanCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/intelligence/map-service.ts b/src/data/intelligence/map-service.ts index 9e33c4aa4..0e46a15f6 100644 --- a/src/data/intelligence/map-service.ts +++ b/src/data/intelligence/map-service.ts @@ -491,7 +491,13 @@ export const make = Effect.gen(function* () { opponentAbbr: string, userTeamId: number | null, profile?: DataAvailabilityProfile - ) { return JSON.stringify({ opponentAbbr, userTeamId, profile: profile ?? null }); } + ) { + return JSON.stringify({ + opponentAbbr, + userTeamId, + profile: profile ?? null, + }); + } const mapIntelCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/player/targets-service.ts b/src/data/player/targets-service.ts index 4e5b9e16b..c1dbd8e95 100644 --- a/src/data/player/targets-service.ts +++ b/src/data/player/targets-service.ts @@ -302,7 +302,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.scrim_count = 0; wideEvent.outcome = "success"; yield* Metric.increment(recentScrimStatsQuerySuccessTotal); - const _empty: ScrimStatPoint[] = []; return _empty; + const _empty: ScrimStatPoint[] = []; + return _empty; } const mapIds = recentScrims.flatMap((s) => @@ -314,7 +315,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.map_count = 0; wideEvent.outcome = "success"; yield* Metric.increment(recentScrimStatsQuerySuccessTotal); - const _empty: ScrimStatPoint[] = []; return _empty; + const _empty: ScrimStatPoint[] = []; + return _empty; } const stats = yield* Effect.tryPromise({ diff --git a/src/data/scrim/ability-timing-service.ts b/src/data/scrim/ability-timing-service.ts index fa2e01a83..abf9ae7ff 100644 --- a/src/data/scrim/ability-timing-service.ts +++ b/src/data/scrim/ability-timing-service.ts @@ -927,7 +927,9 @@ export const make: Effect.Effect< ); } - function abilityTimingCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + function abilityTimingCacheKeyOf(scrimId: number, teamId: number) { + return JSON.stringify({ scrimId, teamId }); + } const abilityTimingCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -943,7 +945,9 @@ export const make: Effect.Effect< }, }); - function fightTimelinesCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + function fightTimelinesCacheKeyOf(scrimId: number, teamId: number) { + return JSON.stringify({ scrimId, teamId }); + } const fightTimelinesCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -963,7 +967,9 @@ export const make: Effect.Effect< mapId: number, team1Name: string, team2Name: string - ) { return JSON.stringify({ mapId, team1Name, team2Name }); } + ) { + return JSON.stringify({ mapId, team1Name, team2Name }); + } const mapAbilityTimingCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/scrim/opponent-service.ts b/src/data/scrim/opponent-service.ts index 9759e867d..378f13336 100644 --- a/src/data/scrim/opponent-service.ts +++ b/src/data/scrim/opponent-service.ts @@ -784,7 +784,9 @@ export const make: Effect.Effect< ); } - function opponentCacheKeyOf(userTeamId: number, opponentAbbr: string) { return JSON.stringify({ userTeamId, opponentAbbr }); } + function opponentCacheKeyOf(userTeamId: number, opponentAbbr: string) { + return JSON.stringify({ userTeamId, opponentAbbr }); + } const mapResultsCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/scrim/overview-service.ts b/src/data/scrim/overview-service.ts index 208d53a50..e464c6b09 100644 --- a/src/data/scrim/overview-service.ts +++ b/src/data/scrim/overview-service.ts @@ -2301,7 +2301,9 @@ export const make: Effect.Effect< ); } - function overviewCacheKeyOf(scrimId: number, teamId: number) { return JSON.stringify({ scrimId, teamId }); } + function overviewCacheKeyOf(scrimId: number, teamId: number) { + return JSON.stringify({ scrimId, teamId }); + } const overviewCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/scrim/scrim-service.ts b/src/data/scrim/scrim-service.ts index cc2eb2c05..13aade097 100644 --- a/src/data/scrim/scrim-service.ts +++ b/src/data/scrim/scrim-service.ts @@ -429,7 +429,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.stat_count = 0; yield* Metric.increment(scrimGetAllStatsForPlayerSuccessTotal); - const _empty: PlayerStat[] = []; return _empty; + const _empty: PlayerStat[] = []; + return _empty; } const scrims = yield* Effect.tryPromise({ @@ -464,7 +465,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.stat_count = 0; yield* Metric.increment(scrimGetAllStatsForPlayerSuccessTotal); - const _empty: PlayerStat[] = []; return _empty; + const _empty: PlayerStat[] = []; + return _empty; } const rawStats = yield* Effect.tryPromise({ @@ -790,7 +792,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.outcome = "success"; wideEvent.win_count = 0; yield* Metric.increment(scrimGetAllMapWinratesForPlayerSuccessTotal); - const _empty: Winrate = []; return _empty; + const _empty: Winrate = []; + return _empty; } const [ @@ -976,7 +979,9 @@ export const make: Effect.Effect = Effect.gen( function finalRoundStatsForPlayerCacheKeyOf( mapId: number, playerName: string - ) { return JSON.stringify({ mapId, playerName }); } + ) { + return JSON.stringify({ mapId, playerName }); + } const finalRoundStatsForPlayerCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -992,7 +997,9 @@ export const make: Effect.Effect = Effect.gen( }, }); - function allStatsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + function allStatsForPlayerCacheKeyOf(scrimIds: number[], name: string) { + return JSON.stringify({ scrimIds, name }); + } const allStatsForPlayerCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -1008,7 +1015,9 @@ export const make: Effect.Effect = Effect.gen( }, }); - function allKillsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + function allKillsForPlayerCacheKeyOf(scrimIds: number[], name: string) { + return JSON.stringify({ scrimIds, name }); + } const allKillsForPlayerCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -1024,7 +1033,9 @@ export const make: Effect.Effect = Effect.gen( }, }); - function allDeathsForPlayerCacheKeyOf(scrimIds: number[], name: string) { return JSON.stringify({ scrimIds, name }); } + function allDeathsForPlayerCacheKeyOf(scrimIds: number[], name: string) { + return JSON.stringify({ scrimIds, name }); + } const allDeathsForPlayerCache = yield* Cache.make({ capacity: CACHE_CAPACITY, @@ -1043,7 +1054,9 @@ export const make: Effect.Effect = Effect.gen( function allMapWinratesForPlayerCacheKeyOf( scrimIds: number[], name: string - ) { return JSON.stringify({ scrimIds, name }); } + ) { + return JSON.stringify({ scrimIds, name }); + } const allMapWinratesForPlayerCache = yield* Cache.make({ capacity: CACHE_CAPACITY, diff --git a/src/data/scrim/types.ts b/src/data/scrim/types.ts index e278492b2..921f347e8 100644 --- a/src/data/scrim/types.ts +++ b/src/data/scrim/types.ts @@ -1,8 +1,5 @@ import type { TrendsAnalysis } from "@/data/comparison/types"; -import type { - SwapTimingOutcome, - SwapWinrateBucket, -} from "@/data/team/types"; +import type { SwapTimingOutcome, SwapWinrateBucket } from "@/data/team/types"; import type { ValidStatColumn } from "@/lib/stat-percentiles"; import type { HeroName, RoleName } from "@/types/heroes"; import type { MapType } from "@prisma/client"; diff --git a/src/data/team/ability-impact-service.ts b/src/data/team/ability-impact-service.ts index f56ba85e1..f848c722c 100644 --- a/src/data/team/ability-impact-service.ts +++ b/src/data/team/ability-impact-service.ts @@ -368,7 +368,11 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.hero_count = 0; yield* Metric.increment(abilityImpactQuerySuccessTotal); - const _empty: AbilityImpactAnalysis = { byHero: {}, availableHeroes: [] }; return _empty; + const _empty: AbilityImpactAnalysis = { + byHero: {}, + availableHeroes: [], + }; + return _empty; } const [allAbility1Events, allAbility2Events] = yield* Effect.tryPromise({ diff --git a/src/data/team/prediction-service.ts b/src/data/team/prediction-service.ts index ca744260e..d8a266670 100644 --- a/src/data/team/prediction-service.ts +++ b/src/data/team/prediction-service.ts @@ -20,14 +20,8 @@ import { TeamHeroPoolService, TeamHeroPoolServiceLive, } from "./hero-pool-service"; -import { - TeamMapModeService, - TeamMapModeServiceLive, -} from "./map-mode-service"; -import { - TeamMatchupService, - TeamMatchupServiceLive, -} from "./matchup-service"; +import { TeamMapModeService, TeamMapModeServiceLive } from "./map-mode-service"; +import { TeamMatchupService, TeamMatchupServiceLive } from "./matchup-service"; import { teamCacheMissTotal, teamCacheRequestTotal } from "./metrics"; import { TeamRoleStatsService, @@ -234,9 +228,7 @@ export const make = Effect.gen(function* () { : Effect.logInfo("team.prediction.getSimulatorContext"); return log.pipe( Effect.annotateLogs(wideEvent), - Effect.andThen( - predictionQueryDuration(Effect.succeed(durationMs)) - ) + Effect.andThen(predictionQueryDuration(Effect.succeed(durationMs))) ); }) ), diff --git a/src/data/team/shared-data-service.ts b/src/data/team/shared-data-service.ts index 0747b1ae7..c4fbf43aa 100644 --- a/src/data/team/shared-data-service.ts +++ b/src/data/team/shared-data-service.ts @@ -88,7 +88,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.roster_size = 0; wideEvent.outcome = "success"; yield* Metric.increment(teamRosterQuerySuccessTotal); - const _empty: string[] = []; return _empty; + const _empty: string[] = []; + return _empty; } const allPlayerStats = yield* Effect.tryPromise({ @@ -139,7 +140,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.roster_size = 0; wideEvent.outcome = "success"; yield* Metric.increment(teamRosterQuerySuccessTotal); - const _empty: string[] = []; return _empty; + const _empty: string[] = []; + return _empty; } let anchorPlayer: string | null = null; @@ -157,7 +159,8 @@ export const make: Effect.Effect = Effect.gen( wideEvent.roster_size = 0; wideEvent.outcome = "success"; yield* Metric.increment(teamRosterQuerySuccessTotal); - const _empty: string[] = []; return _empty; + const _empty: string[] = []; + return _empty; } function findTeamNameForMap(mapDataId: number): string | null { @@ -563,10 +566,7 @@ export const make: Effect.Effect = Effect.gen( ), }); - function baseDataCacheKeyOf( - teamId: number, - options?: BaseTeamDataOptions - ) { + function baseDataCacheKeyOf(teamId: number, options?: BaseTeamDataOptions) { return `${teamId}:${JSON.stringify(options ?? {})}`; } diff --git a/src/data/team/stats-service.ts b/src/data/team/stats-service.ts index be5aea52c..4e91b01ee 100644 --- a/src/data/team/stats-service.ts +++ b/src/data/team/stats-service.ts @@ -501,7 +501,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.map_count = 0; yield* Metric.increment(statsQuerySuccessTotal); - const _empty: TopMapByPlaytime[] = []; return _empty; + const _empty: TopMapByPlaytime[] = []; + return _empty; } const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); @@ -510,7 +511,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.map_count = 0; yield* Metric.increment(statsQuerySuccessTotal); - const _empty: TopMapByPlaytime[] = []; return _empty; + const _empty: TopMapByPlaytime[] = []; + return _empty; } const mapDataIdToName = new Map(); diff --git a/src/data/team/trends-service.ts b/src/data/team/trends-service.ts index c13a2fb95..8d912d4b3 100644 --- a/src/data/team/trends-service.ts +++ b/src/data/team/trends-service.ts @@ -229,7 +229,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.match_count = 0; yield* Metric.increment(trendsQuerySuccessTotal); - const _empty: ProcessedMatchResult[] = []; return _empty; + const _empty: ProcessedMatchResult[] = []; + return _empty; } const sharedData = yield* shared.getBaseTeamData(teamId, { @@ -290,7 +291,8 @@ export const make = Effect.gen(function* () { wideEvent.outcome = "success"; wideEvent.period_count = 0; yield* Metric.increment(trendsQuerySuccessTotal); - const _empty: WinrateDataPoint[] = []; return _empty; + const _empty: WinrateDataPoint[] = []; + return _empty; } type PeriodKey = string; @@ -647,10 +649,7 @@ export const make = Effect.gen(function* () { groupBy?: "week" | "month"; dateRange?: TeamDateRange; }; - const dr = - parsed.dateRange?.from - ? parsed.dateRange - : undefined; + const dr = parsed.dateRange?.from ? parsed.dateRange : undefined; return getWinrateOverTime(Number(teamIdStr), parsed.groupBy, dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/lib/ai/tools.ts b/src/lib/ai/tools.ts index 40a00cbb6..204958d2c 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,13 +1,8 @@ import { ComparisonAggregationService } from "@/data/comparison"; -import { - MapIntelligenceService, -} from "@/data/intelligence"; +import { MapIntelligenceService } from "@/data/intelligence"; import { IntelligenceService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; -import { - ScrimAbilityTimingService, - ScrimOverviewService, -} from "@/data/scrim"; +import { ScrimAbilityTimingService, ScrimOverviewService } from "@/data/scrim"; import { TeamAbilityImpactService, TeamFightStatsService, @@ -272,7 +267,9 @@ export function buildTools(opts: { return { error: `Scrim ${scrimId} not found for team ${teamId}.` }; } const data = await AppRuntime.runPromise( - ScrimOverviewService.pipe(Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId))) + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId)) + ) ); return formatScrimOverview(data); }, @@ -305,7 +302,9 @@ export function buildTools(opts: { .filter((id) => validIds.has(id)) .map(async (scrimId) => { const data = await AppRuntime.runPromise( - ScrimOverviewService.pipe(Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId))) + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(scrimId, teamId)) + ) ); return { scrimId, ...formatScrimOverview(data) }; }) @@ -439,17 +438,18 @@ export function buildTools(opts: { }), execute: async ({ teamId }) => { assertTeamAccess(teamId); - const [winrateOverTime, recentForm, streakInfo] = await AppRuntime.runPromise( - TeamTrendsService.pipe( - Effect.flatMap((svc) => - Effect.all([ - svc.getWinrateOverTime(teamId), - svc.getRecentForm(teamId), - svc.getStreakInfo(teamId), - ]) + const [winrateOverTime, recentForm, streakInfo] = + await AppRuntime.runPromise( + TeamTrendsService.pipe( + Effect.flatMap((svc) => + Effect.all([ + svc.getWinrateOverTime(teamId), + svc.getRecentForm(teamId), + svc.getStreakInfo(teamId), + ]) + ) ) - ) - ); + ); return { winrateOverTime: winrateOverTime.map((dp) => ({ period: dp.period, @@ -602,7 +602,9 @@ export function buildTools(opts: { if (!scrim) return { error: `Scrim ${scrimId} not found.` }; const data = await AppRuntime.runPromise( - ScrimAbilityTimingService.pipe(Effect.flatMap((svc) => svc.getScrimAbilityTiming(scrimId, teamId))) + ScrimAbilityTimingService.pipe( + Effect.flatMap((svc) => svc.getScrimAbilityTiming(scrimId, teamId)) + ) ); if (data.rows.length === 0) { @@ -778,7 +780,9 @@ export function buildTools(opts: { if (!scrim) return { error: `Scrim ${scrimId} not found.` }; const data = await AppRuntime.runPromise( - ScrimAbilityTimingService.pipe(Effect.flatMap((svc) => svc.getScrimFightTimelines(scrimId, teamId))) + ScrimAbilityTimingService.pipe( + Effect.flatMap((svc) => svc.getScrimFightTimelines(scrimId, teamId)) + ) ); if (data.fights.length === 0) { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 13b6acc63..f37951eed 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -276,7 +276,9 @@ export async function isAuthedToViewScrim(id: number) { if (!user) return false; const listOfViewableScrims = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getUserViewableScrims(user.id))) + ScrimService.pipe( + Effect.flatMap((svc) => svc.getUserViewableScrims(user.id)) + ) ); if (listOfViewableScrims.some((scrim) => scrim.id === id)) { diff --git a/src/lib/calculate-stats.ts b/src/lib/calculate-stats.ts index c07a17284..2432a1deb 100644 --- a/src/lib/calculate-stats.ts +++ b/src/lib/calculate-stats.ts @@ -30,7 +30,11 @@ export async function calculateStats(mapDataId: number, playerName: string) { mapMVP, ] = await Promise.all([ AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapDataId, playerName))) + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(mapDataId, playerName) + ) + ) ), groupKillsIntoFights(mapDataId), prisma.roundEnd.findFirst({ @@ -38,7 +42,11 @@ export async function calculateStats(mapDataId: number, playerName: string) { orderBy: { round_number: "desc" }, }), AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapDataId, playerName))) + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(mapDataId, playerName) + ) + ) ), getAverageUltChargeTime(mapDataId, playerName), getAverageTimeToUseUlt(mapDataId, playerName), diff --git a/src/lib/mvp-score.ts b/src/lib/mvp-score.ts index 4ddee1438..58d5b3ddb 100644 --- a/src/lib/mvp-score.ts +++ b/src/lib/mvp-score.ts @@ -205,7 +205,11 @@ export async function calculateMVPScore({ minTimeSeconds = 300, }: CalculateMVPScoreParams): Promise { const playerStats = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getFinalRoundStatsForPlayer(mapId, playerName))) + ScrimService.pipe( + Effect.flatMap((svc) => + svc.getFinalRoundStatsForPlayer(mapId, playerName) + ) + ) ); if (playerStats.length === 0) { From 65ed49ca4d59ad21ec6fa7d4823ca774cb6b8a2d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:40:40 -0400 Subject: [PATCH 31/42] Delete migration-parity.test.ts --- src/data/__tests__/migration-parity.test.ts | 1805 ------------------- 1 file changed, 1805 deletions(-) delete mode 100644 src/data/__tests__/migration-parity.test.ts diff --git a/src/data/__tests__/migration-parity.test.ts b/src/data/__tests__/migration-parity.test.ts deleted file mode 100644 index b533706ac..000000000 --- a/src/data/__tests__/migration-parity.test.ts +++ /dev/null @@ -1,1805 +0,0 @@ -/* oxlint-disable typescript-eslint/consistent-type-assertions, typescript-eslint/require-await */ -/** - * Migration parity tests: verify new Effect services produce identical results - * to the old DTO implementations given the same Prisma mock data. - * - * Strategy: - * - Mock Prisma with controlled fixture data - * - Call the old DTO function directly (they use the same Prisma singleton) - * - Run the new Effect service method via Effect.runPromise with a test layer - * - Compare results structurally - */ - -import { Effect, Layer, Logger } from "effect"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/prisma"); -vi.mock("server-only", () => ({})); -vi.mock("react", () => ({ - cache: unknown>(fn: T) => fn, -})); -vi.mock("@/instrumentation", async () => { - const { Layer } = await import("effect"); - return { EffectObservabilityLive: Layer.empty }; -}); -vi.mock("@/lib/axiom/server", () => ({ - logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, -})); - -import prisma from "@/lib/__mocks__/prisma"; - -// --------------------------------------------------------------------------- -// User domain -// --------------------------------------------------------------------------- -import { getUser, getTeamsWithPerms } from "@/data/user-dto"; -import { UserService, UserServiceLive } from "@/data/user/service"; - -const TestLogger = Logger.none; - -describe("User service parity", () => { - const mockUser = { - id: "user-1", - name: "Test User", - email: "test@example.com", - emailVerified: null, - image: null, - role: "USER" as const, - createdAt: new Date("2024-01-01"), - updatedAt: new Date("2024-01-01"), - }; - - beforeEach(() => { - prisma.user.findFirst.mockResolvedValue(mockUser); - prisma.team.findMany.mockResolvedValue([]); - prisma.appSettings.findFirst.mockResolvedValue(null); - }); - - it("getUser returns same result", async () => { - const oldResult = await getUser("test@example.com"); - - const testLayer = UserServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser("test@example.com")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getUser with undefined email returns null for both", async () => { - const oldResult = await getUser(undefined); - - const testLayer = UserServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(undefined))).pipe( - Effect.provide(testLayer) - ) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toBeNull(); - }); - - it("getTeamsWithPerms returns same result", async () => { - const oldResult = await getTeamsWithPerms("test@example.com"); - - const testLayer = UserServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getTeamsWithPerms("test@example.com")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); -}); - -// --------------------------------------------------------------------------- -// Tournament domain -// --------------------------------------------------------------------------- -import { getTournament, getUserTournaments } from "@/data/tournament-dto"; -import { - TournamentService, - TournamentServiceLive, -} from "@/data/tournament/tournament-service"; - -describe("Tournament service parity", () => { - const mockTournament = { - id: 1, - name: "Test Tournament", - creatorId: "user-1", - format: "SINGLE_ELIMINATION" as const, - status: "IN_PROGRESS" as const, - teamSize: 8, - createdAt: new Date("2024-01-01"), - updatedAt: new Date("2024-01-01"), - teams: [], - rounds: [], - matches: [], - }; - - beforeEach(() => { - prisma.tournament.findUnique.mockResolvedValue(mockTournament); - prisma.tournament.findMany.mockResolvedValue([]); - }); - - it("getTournament returns same result", async () => { - const oldResult = await getTournament(1); - - const testLayer = TournamentServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TournamentService.pipe( - Effect.flatMap((svc) => svc.getTournament(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getUserTournaments returns same result", async () => { - const oldResult = await getUserTournaments("user-1"); - - const testLayer = TournamentServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TournamentService.pipe( - Effect.flatMap((svc) => svc.getUserTournaments("user-1")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); -}); - -// --------------------------------------------------------------------------- -// Hero domain -// --------------------------------------------------------------------------- -import { getAllKillsForHero, getAllDeathsForHero } from "@/data/hero-dto"; -import { HeroService, HeroServiceLive } from "@/data/hero/service"; - -describe("Hero service parity", () => { - const mockKills = [ - { - id: 1, - scrimId: 1, - event_type: "kill" as const, - match_time: 100, - MapDataId: 10, - attacker_team: "Team A", - attacker_name: "Player1", - attacker_hero: "Ana", - victim_team: "Team B", - victim_name: "Player2", - victim_hero: "Genji", - event_ability: "Biotic Rifle", - event_damage: 70, - is_critical_hit: false, - is_environmental: false, - round_number: 1, - objective_index: 0, - attacker_x: 0, - attacker_y: 0, - attacker_z: 0, - victim_x: 0, - victim_y: 0, - victim_z: 0, - }, - ]; - - beforeEach(() => { - prisma.scrim.findMany.mockResolvedValue([ - { - id: 1, - maps: [{ id: 1, mapData: [{ id: 10 }] }], - } as never, - ]); - prisma.kill.findMany.mockResolvedValue(mockKills as never); - }); - - it("getAllKillsForHero returns same result", async () => { - const oldResult = await getAllKillsForHero([1], "Ana"); - - const testLayer = HeroServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - HeroService.pipe( - Effect.flatMap((svc) => svc.getAllKillsForHero([1], "Ana")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getAllDeathsForHero returns same result", async () => { - const oldResult = await getAllDeathsForHero([1], "Ana"); - - const testLayer = HeroServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - HeroService.pipe( - Effect.flatMap((svc) => svc.getAllDeathsForHero([1], "Ana")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); -}); - -// --------------------------------------------------------------------------- -// Admin domain -// --------------------------------------------------------------------------- -import { getUnlabeledMatches } from "@/data/data-labeling-dto"; -import { - DataLabelingService, - DataLabelingServiceLive, -} from "@/data/admin/data-labeling-service"; - -describe("Admin service parity", () => { - beforeEach(() => { - prisma.scoutingMatch.findMany.mockResolvedValue([]); - prisma.scoutingMatch.count.mockResolvedValue(0); - }); - - it("getUnlabeledMatches returns same result for empty data", async () => { - const oldResult = await getUnlabeledMatches(0, 10); - - const testLayer = DataLabelingServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - DataLabelingService.pipe( - Effect.flatMap((svc) => svc.getUnlabeledMatches(0, 10)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); -}); - -// --------------------------------------------------------------------------- -// Scrim domain (basic) -// --------------------------------------------------------------------------- -import { getScrim } from "@/data/scrim-dto"; -import { ScrimService, ScrimServiceLive } from "@/data/scrim/scrim-service"; - -describe("Scrim service parity", () => { - const mockScrim = { - id: 1, - name: "Test Scrim", - date: new Date("2024-06-01"), - creatorId: "user-1", - teamId: 1, - guestMode: false, - }; - - beforeEach(() => { - prisma.scrim.findFirst.mockResolvedValue(mockScrim as never); - }); - - it("getScrim returns same result", async () => { - const oldResult = await getScrim(1); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(1))).pipe( - Effect.provide(testLayer) - ) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getScrim with non-existent id returns null for both", async () => { - prisma.scrim.findFirst.mockResolvedValue(null); - - const oldResult = await getScrim(999); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(999))).pipe( - Effect.provide(testLayer) - ) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toBeNull(); - }); -}); - -// --------------------------------------------------------------------------- -// Team shared-core (pure functions — no mocking needed) -// --------------------------------------------------------------------------- -import { - buildFinalRoundMap, - buildMatchStartMap, - findTeamNameForMapInMemory, -} from "@/data/team-shared-core"; -import { - buildFinalRoundMap as newBuildFinalRoundMap, - buildMatchStartMap as newBuildMatchStartMap, - findTeamNameForMapInMemory as newFindTeamNameForMapInMemory, -} from "@/data/team/shared-core"; - -describe("Team shared-core pure function parity", () => { - it("findTeamNameForMapInMemory returns same result", () => { - const stats = [ - { player_name: "Player1", player_team: "Team A", MapDataId: 1 }, - { player_name: "Player2", player_team: "Team A", MapDataId: 1 }, - { player_name: "Player3", player_team: "Team B", MapDataId: 1 }, - ]; - const roster = new Set(["Player1", "Player2"]); - - const oldResult = findTeamNameForMapInMemory(1, stats, roster); - const newResult = newFindTeamNameForMapInMemory(1, stats, roster); - - expect(newResult).toEqual(oldResult); - expect(newResult).toBe("Team A"); - }); - - it("buildMatchStartMap returns same result", () => { - const matchStarts = [ - { - id: 1, - MapDataId: 10, - map_name: "Lijiang Tower", - map_type: "Control" as const, - team_1_name: "A", - team_2_name: "B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - { - id: 2, - MapDataId: 20, - map_name: "Numbani", - map_type: "Hybrid" as const, - team_1_name: "A", - team_2_name: "B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - ] as Parameters[0]; - - const oldResult = buildMatchStartMap(matchStarts); - const newResult = newBuildMatchStartMap(matchStarts); - - expect(newResult).toEqual(oldResult); - expect(newResult.size).toBe(2); - expect(newResult.get(10)?.map_name).toBe("Lijiang Tower"); - }); - - it("buildFinalRoundMap keeps highest round number per map", () => { - const rounds = [ - { - id: 1, - MapDataId: 10, - round_number: 1, - match_time: 100, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 1, - team_2_score: 0, - objective_index: 0, - capturing_team: null, - }, - { - id: 2, - MapDataId: 10, - round_number: 3, - match_time: 300, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 2, - team_2_score: 1, - objective_index: 0, - capturing_team: null, - }, - { - id: 3, - MapDataId: 10, - round_number: 2, - match_time: 200, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 1, - team_2_score: 1, - objective_index: 0, - capturing_team: null, - }, - ] as Parameters[0]; - - const oldResult = buildFinalRoundMap(rounds); - const newResult = newBuildFinalRoundMap(rounds); - - expect(newResult).toEqual(oldResult); - expect(newResult.get(10)?.round_number).toBe(3); - }); -}); - -// --------------------------------------------------------------------------- -// Comparison computation (pure functions) -// --------------------------------------------------------------------------- -import { - aggregatePlayerStats as oldAggregate, - calculateTrends as oldCalculateTrends, -} from "@/data/comparison-dto"; -import { - aggregatePlayerStats as newAggregate, - calculateTrends as newCalculateTrends, -} from "@/data/comparison/computation"; - -describe("Comparison computation parity", () => { - it("aggregatePlayerStats returns same result for empty input", () => { - const oldResult = oldAggregate([], []); - const newResult = newAggregate([], []); - expect(newResult).toEqual(oldResult); - }); - - it("calculateTrends returns same result for empty input", () => { - const oldResult = oldCalculateTrends([], []); - const newResult = newCalculateTrends([], []); - expect(newResult).toEqual(oldResult); - }); -}); - -// --------------------------------------------------------------------------- -// Scouting opponent strength (division-by-zero fix verification) -// --------------------------------------------------------------------------- -import { - OpponentStrengthService, - OpponentStrengthServiceLive, -} from "@/data/scouting/opponent-strength-service"; - -describe("Opponent strength edge cases", () => { - it("getTeamStrengthPercentile handles single-team case without NaN", async () => { - prisma.scoutingMatch.findMany.mockResolvedValue([ - { - id: 1, - team1: "TST", - team1FullName: "Test Team", - team2: "OPP", - team2FullName: "Opponent", - team1Score: 3, - team2Score: 0, - matchDate: new Date("2024-06-01"), - tournamentId: 1, - vods: [], - } as never, - ]); - - const testLayer = OpponentStrengthServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const result = await Effect.runPromise( - OpponentStrengthService.pipe( - Effect.flatMap((svc) => svc.getTeamStrengthPercentile("TST")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(result).not.toBeNaN(); - if (result !== null) { - expect(result).toBeGreaterThanOrEqual(0); - expect(result).toBeLessThanOrEqual(100); - } - }); -}); - -// --------------------------------------------------------------------------- -// Team shared data service parity -// --------------------------------------------------------------------------- -import { - getTeamRoster, - getBaseTeamData, - getExtendedTeamData, -} from "@/data/team-shared-data"; -import { - TeamSharedDataService, - TeamSharedDataServiceLive, -} from "@/data/team/shared-data-service"; - -describe("Team shared data service parity", () => { - const mockMapRecords = [ - { - id: 1, - name: "Lijiang Tower", - mapData: [{ id: 10 }, { id: 11 }], - }, - { - id: 2, - name: "Numbani", - mapData: [{ id: 20 }], - }, - ]; - - const mockPlayerStats = [ - { - player_name: "Alice", - player_team: "Team A", - MapDataId: 10, - player_hero: "Ana", - hero_time_played: 600, - eliminations: 5, - final_blows: 3, - deaths: 2, - offensive_assists: 10, - hero_damage_dealt: 3000, - damage_taken: 1500, - healing_dealt: 8000, - ultimates_earned: 3, - ultimates_used: 2, - }, - { - player_name: "Bob", - player_team: "Team A", - MapDataId: 10, - player_hero: "Reinhardt", - hero_time_played: 600, - eliminations: 8, - final_blows: 5, - deaths: 3, - offensive_assists: 0, - hero_damage_dealt: 5000, - damage_taken: 8000, - healing_dealt: 0, - ultimates_earned: 2, - ultimates_used: 2, - }, - { - player_name: "Charlie", - player_team: "Team B", - MapDataId: 10, - player_hero: "Genji", - hero_time_played: 600, - eliminations: 10, - final_blows: 7, - deaths: 4, - offensive_assists: 0, - hero_damage_dealt: 7000, - damage_taken: 3000, - healing_dealt: 0, - ultimates_earned: 4, - ultimates_used: 3, - }, - { - player_name: "Alice", - player_team: "Team A", - MapDataId: 11, - player_hero: "Mercy", - hero_time_played: 600, - eliminations: 1, - final_blows: 0, - deaths: 2, - offensive_assists: 15, - hero_damage_dealt: 500, - damage_taken: 1000, - healing_dealt: 10000, - ultimates_earned: 5, - ultimates_used: 4, - }, - { - player_name: "Bob", - player_team: "Team A", - MapDataId: 11, - player_hero: "Winston", - hero_time_played: 600, - eliminations: 6, - final_blows: 4, - deaths: 5, - offensive_assists: 0, - hero_damage_dealt: 4000, - damage_taken: 7000, - healing_dealt: 0, - ultimates_earned: 3, - ultimates_used: 2, - }, - { - player_name: "Alice", - player_team: "Team A", - MapDataId: 20, - player_hero: "Ana", - hero_time_played: 400, - eliminations: 3, - final_blows: 2, - deaths: 1, - offensive_assists: 8, - hero_damage_dealt: 2000, - damage_taken: 1000, - healing_dealt: 6000, - ultimates_earned: 2, - ultimates_used: 1, - }, - { - player_name: "Bob", - player_team: "Team A", - MapDataId: 20, - player_hero: "Reinhardt", - hero_time_played: 400, - eliminations: 5, - final_blows: 3, - deaths: 2, - offensive_assists: 0, - hero_damage_dealt: 3000, - damage_taken: 5000, - healing_dealt: 0, - ultimates_earned: 1, - ultimates_used: 1, - }, - ]; - - const mockMatchStarts = [ - { - id: 1, - MapDataId: 10, - map_name: "Lijiang Tower", - map_type: "Control" as const, - team_1_name: "Team A", - team_2_name: "Team B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - { - id: 2, - MapDataId: 11, - map_name: "Lijiang Tower", - map_type: "Control" as const, - team_1_name: "Team A", - team_2_name: "Team B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - { - id: 3, - MapDataId: 20, - map_name: "Numbani", - map_type: "Hybrid" as const, - team_1_name: "Team A", - team_2_name: "Team B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - ]; - - const mockFinalRounds = [ - { - id: 1, - MapDataId: 10, - round_number: 3, - match_time: 300, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 2, - team_2_score: 1, - objective_index: 0, - capturing_team: null, - }, - { - id: 2, - MapDataId: 11, - round_number: 2, - match_time: 200, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 2, - team_2_score: 0, - objective_index: 0, - capturing_team: null, - }, - { - id: 3, - MapDataId: 20, - round_number: 4, - match_time: 400, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 3, - team_2_score: 2, - objective_index: 0, - capturing_team: null, - }, - ]; - - it("getTeamRoster returns same roster from player stats across maps", async () => { - // First call for roster itself, second call for base data - prisma.map.findMany.mockResolvedValue(mockMapRecords as never); - prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); - - const oldResult = await getTeamRoster(1); - - const testLayer = TeamSharedDataServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getTeamRoster(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult.sort()).toEqual(oldResult.sort()); - }); - - it("getTeamRoster returns empty array when no maps exist", async () => { - prisma.map.findMany.mockResolvedValue([] as never); - - const oldResult = await getTeamRoster(999); - - const testLayer = TeamSharedDataServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getTeamRoster(999)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toEqual([]); - }); - - it("getBaseTeamData returns same structure with mock data", async () => { - prisma.map.findMany.mockResolvedValue(mockMapRecords as never); - prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); - prisma.matchStart.findMany.mockResolvedValue(mockMatchStarts as never); - prisma.roundEnd.findMany.mockResolvedValue(mockFinalRounds as never); - prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); - prisma.payloadProgress.findMany.mockResolvedValue([] as never); - prisma.pointProgress.findMany.mockResolvedValue([] as never); - - const oldResult = await getBaseTeamData(1); - - const testLayer = TeamSharedDataServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getBaseTeamData(1)) - ).pipe(Effect.provide(testLayer)) - ); - - // Compare scalar fields (Set is not structurally comparable) - expect(newResult.teamId).toEqual(oldResult.teamId); - expect(newResult.teamRoster.sort()).toEqual(oldResult.teamRoster.sort()); - expect(newResult.mapDataIds.sort()).toEqual(oldResult.mapDataIds.sort()); - expect(newResult.mapDataRecords).toEqual(oldResult.mapDataRecords); - expect(newResult.allPlayerStats).toEqual(oldResult.allPlayerStats); - expect(newResult.matchStarts).toEqual(oldResult.matchStarts); - expect(newResult.finalRounds).toEqual(oldResult.finalRounds); - expect(newResult.captures).toEqual(oldResult.captures); - expect(newResult.payloadProgresses).toEqual(oldResult.payloadProgresses); - expect(newResult.pointProgresses).toEqual(oldResult.pointProgresses); - }); - - it("getBaseTeamData returns empty structure when no maps", async () => { - prisma.map.findMany.mockResolvedValue([] as never); - prisma.playerStat.findMany.mockResolvedValue([] as never); - - const oldResult = await getBaseTeamData(999); - - const testLayer = TeamSharedDataServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getBaseTeamData(999)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult.teamId).toEqual(oldResult.teamId); - expect(newResult.teamRoster).toEqual(oldResult.teamRoster); - expect(newResult.mapDataRecords).toEqual(oldResult.mapDataRecords); - expect(newResult.mapDataIds).toEqual(oldResult.mapDataIds); - expect(newResult.allPlayerStats).toEqual(oldResult.allPlayerStats); - expect(newResult.matchStarts).toEqual(oldResult.matchStarts); - }); - - it("getExtendedTeamData returns same result with kills/rezzes/ults", async () => { - const mockKills = [ - { - id: 1, - scrimId: 1, - event_type: "kill" as const, - match_time: 50, - MapDataId: 10, - attacker_team: "Team A", - attacker_name: "Alice", - attacker_hero: "Ana", - victim_team: "Team B", - victim_name: "Charlie", - victim_hero: "Genji", - event_ability: "Biotic Rifle", - event_damage: 70, - is_critical_hit: false, - is_environmental: false, - round_number: 1, - objective_index: 0, - attacker_x: 0, - attacker_y: 0, - attacker_z: 0, - victim_x: 0, - victim_y: 0, - victim_z: 0, - }, - ]; - const mockRezzes = [ - { - id: 1, - scrimId: 1, - event_type: "mercy_rez" as const, - match_time: 60, - MapDataId: 10, - resurrecter_team: "Team A", - resurrecter_player: "Alice", - resurrecter_hero: "Mercy", - resurrectee_team: "Team A", - resurrectee_player: "Bob", - resurrectee_hero: "Reinhardt", - round_number: 1, - objective_index: 0, - }, - ]; - const mockUltimates = [ - { - id: 1, - scrimId: 1, - event_type: "ultimate_start" as const, - match_time: 70, - MapDataId: 10, - player_team: "Team A", - player_name: "Alice", - player_hero: "Ana", - ultimate_id: 1, - round_number: 1, - objective_index: 0, - }, - ]; - - prisma.map.findMany.mockResolvedValue(mockMapRecords as never); - prisma.playerStat.findMany.mockResolvedValue(mockPlayerStats as never); - prisma.matchStart.findMany.mockResolvedValue(mockMatchStarts as never); - prisma.roundEnd.findMany.mockResolvedValue(mockFinalRounds as never); - prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); - prisma.payloadProgress.findMany.mockResolvedValue([] as never); - prisma.pointProgress.findMany.mockResolvedValue([] as never); - prisma.kill.findMany.mockResolvedValue(mockKills as never); - prisma.mercyRez.findMany.mockResolvedValue(mockRezzes as never); - prisma.ultimateStart.findMany.mockResolvedValue(mockUltimates as never); - - const oldResult = await getExtendedTeamData(1); - - const testLayer = TeamSharedDataServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getExtendedTeamData(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult.teamId).toEqual(oldResult.teamId); - expect(newResult.teamRoster.sort()).toEqual(oldResult.teamRoster.sort()); - expect(newResult.allKills).toEqual(oldResult.allKills); - expect(newResult.allRezzes).toEqual(oldResult.allRezzes); - expect(newResult.allUltimates).toEqual(oldResult.allUltimates); - }); -}); - -// --------------------------------------------------------------------------- -// Team stats service parity -// --------------------------------------------------------------------------- -import { - getTeamWinrates, - getTopMapsByPlaytime, - getTeamNameForRoster, -} from "@/data/team-stats-dto"; -import { - TeamStatsService, - TeamStatsServiceLive, -} from "@/data/team/stats-service"; - -describe("Team stats service parity", () => { - it("getTeamWinrates returns zeroed structure with empty data", async () => { - prisma.map.findMany.mockResolvedValue([] as never); - prisma.playerStat.findMany.mockResolvedValue([] as never); - - const oldResult = await getTeamWinrates(999); - - const testLayer = TeamStatsServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamWinrates(999)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult.overallWins).toBe(0); - expect(newResult.overallLosses).toBe(0); - expect(newResult.overallWinrate).toBe(0); - expect(newResult.byMap).toEqual({}); - }); - - it("getTeamWinrates computes same winrates with Control map data", async () => { - const mapRecords = [ - { id: 1, name: "Lijiang Tower", mapData: [{ id: 10 }] }, - ]; - const playerStats = [ - { - player_name: "Alice", - player_team: "Team A", - MapDataId: 10, - player_hero: "Ana", - hero_time_played: 600, - eliminations: 5, - final_blows: 3, - deaths: 2, - offensive_assists: 10, - hero_damage_dealt: 3000, - damage_taken: 1500, - healing_dealt: 8000, - ultimates_earned: 3, - ultimates_used: 2, - }, - { - player_name: "Bob", - player_team: "Team A", - MapDataId: 10, - player_hero: "Reinhardt", - hero_time_played: 600, - eliminations: 8, - final_blows: 5, - deaths: 3, - offensive_assists: 0, - hero_damage_dealt: 5000, - damage_taken: 8000, - healing_dealt: 0, - ultimates_earned: 2, - ultimates_used: 2, - }, - { - player_name: "Eve", - player_team: "Team B", - MapDataId: 10, - player_hero: "Genji", - hero_time_played: 600, - eliminations: 10, - final_blows: 7, - deaths: 4, - offensive_assists: 0, - hero_damage_dealt: 7000, - damage_taken: 3000, - healing_dealt: 0, - ultimates_earned: 4, - ultimates_used: 3, - }, - ]; - const matchStarts = [ - { - id: 1, - MapDataId: 10, - map_name: "Lijiang Tower", - map_type: "Control" as const, - team_1_name: "Team A", - team_2_name: "Team B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - ]; - const finalRounds = [ - { - id: 1, - MapDataId: 10, - round_number: 3, - match_time: 300, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 2, - team_2_score: 1, - objective_index: 0, - capturing_team: null, - }, - ]; - - prisma.map.findMany.mockResolvedValue(mapRecords as never); - prisma.playerStat.findMany.mockResolvedValue(playerStats as never); - prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); - prisma.roundEnd.findMany.mockResolvedValue(finalRounds as never); - prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); - prisma.payloadProgress.findMany.mockResolvedValue([] as never); - prisma.pointProgress.findMany.mockResolvedValue([] as never); - - const oldResult = await getTeamWinrates(1); - - const testLayer = TeamStatsServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamWinrates(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getTopMapsByPlaytime returns same sorted results", async () => { - const mapRecords = [ - { id: 1, name: "Lijiang Tower", mapData: [{ id: 10 }] }, - { id: 2, name: "Numbani", mapData: [{ id: 20 }] }, - ]; - const matchEnds = [ - { match_time: 600, MapDataId: 10 }, - { match_time: 400, MapDataId: 20 }, - ]; - - prisma.map.findMany.mockResolvedValue(mapRecords as never); - prisma.matchEnd.findMany.mockResolvedValue(matchEnds as never); - - const oldResult = await getTopMapsByPlaytime(1); - - const testLayer = TeamStatsServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTopMapsByPlaytime(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult[0].name).toBe("Lijiang Tower"); - expect(newResult[0].playtime).toBe(600); - }); - - it("getTeamNameForRoster returns same team name", async () => { - // Roster setup: Alice and Bob are the team - const mapRecords = [{ mapData: [{ id: 10 }] }]; - const rosterPlayerStats = [ - { player_name: "Alice", player_team: "Team A", MapDataId: 10 }, - { player_name: "Bob", player_team: "Team A", MapDataId: 10 }, - { player_name: "Eve", player_team: "Team B", MapDataId: 10 }, - ]; - const distinctStats = [ - { player_name: "Alice", player_team: "Team A" }, - { player_name: "Bob", player_team: "Team A" }, - { player_name: "Eve", player_team: "Team B" }, - ]; - - // Mock for getTeamRoster (called by both old and new) - prisma.map.findMany.mockResolvedValue(mapRecords as never); - prisma.playerStat.findMany - .mockResolvedValueOnce(rosterPlayerStats as never) // old: getTeamRoster - .mockResolvedValueOnce(distinctStats as never) // old: getTeamNameForRoster - .mockResolvedValueOnce(rosterPlayerStats as never) // new: getTeamRoster - .mockResolvedValueOnce(distinctStats as never); // new: getTeamNameForRoster - - const oldResult = await getTeamNameForRoster(1, 10); - - const testLayer = TeamStatsServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamNameForRoster(1, 10)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toBe("Team A"); - }); -}); - -// --------------------------------------------------------------------------- -// Scrim service (expanded) — kills, deaths, stats, winrates -// --------------------------------------------------------------------------- -import { - getAllKillsForPlayer, - getAllDeathsForPlayer, - getAllStatsForPlayer, - getAllMapWinratesForPlayer, -} from "@/data/scrim-dto"; - -describe("Scrim service expanded parity", () => { - const mockScrimsMaps = [ - { - id: 1, - maps: [{ mapData: [{ id: 10 }] }], - date: new Date("2024-06-01"), - }, - ]; - - const mockKills = [ - { - id: 1, - scrimId: 1, - event_type: "kill" as const, - match_time: 100, - MapDataId: 10, - attacker_team: "Team A", - attacker_name: "Alice", - attacker_hero: "Ana", - victim_team: "Team B", - victim_name: "Bob", - victim_hero: "Genji", - event_ability: "Biotic Rifle", - event_damage: 70, - is_critical_hit: false, - is_environmental: false, - round_number: 1, - objective_index: 0, - attacker_x: 0, - attacker_y: 0, - attacker_z: 0, - victim_x: 0, - victim_y: 0, - victim_z: 0, - }, - { - id: 2, - scrimId: 1, - event_type: "kill" as const, - match_time: 150, - MapDataId: 10, - attacker_team: "Team B", - attacker_name: "Bob", - attacker_hero: "Genji", - victim_team: "Team A", - victim_name: "Alice", - victim_hero: "Ana", - event_ability: "Dragonblade", - event_damage: 120, - is_critical_hit: false, - is_environmental: false, - round_number: 1, - objective_index: 0, - attacker_x: 0, - attacker_y: 0, - attacker_z: 0, - victim_x: 0, - victim_y: 0, - victim_z: 0, - }, - ]; - - it("getAllKillsForPlayer returns same kills", async () => { - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - // Only Alice's kills (attacker_name = Alice) - prisma.kill.findMany.mockResolvedValue([mockKills[0]] as never); - - const oldResult = await getAllKillsForPlayer([1], "Alice"); - - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - prisma.kill.findMany.mockResolvedValue([mockKills[0]] as never); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe( - Effect.flatMap((svc) => svc.getAllKillsForPlayer([1], "Alice")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getAllDeathsForPlayer returns same deaths", async () => { - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - // Alice's deaths (victim_name = Alice) - prisma.kill.findMany.mockResolvedValue([mockKills[1]] as never); - - const oldResult = await getAllDeathsForPlayer([1], "Alice"); - - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - prisma.kill.findMany.mockResolvedValue([mockKills[1]] as never); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe( - Effect.flatMap((svc) => svc.getAllDeathsForPlayer([1], "Alice")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getAllStatsForPlayer returns same results from raw SQL mock", async () => { - const mockRawStats = [ - { - id: 100, - scrimId: 1, - event_type: "player_stat", - match_time: 600, - MapDataId: 10, - player_name: "Alice", - player_team: "Team A", - player_hero: "Ana", - hero_time_played: 600, - eliminations: 5, - final_blows: 3, - deaths: 2, - all_damage_dealt: 4000, - barrier_damage_dealt: 500, - hero_damage_dealt: 3000, - healing_dealt: 8000, - healing_received: 1000, - self_healing: 200, - damage_taken: 1500, - damage_blocked: 0, - defensive_assists: 5, - offensive_assists: 10, - ultimates_earned: 3, - ultimates_used: 2, - multikill_best: 0, - multikills: 0, - solo_kills: 1, - objective_kills: 2, - environmental_kills: 0, - environmental_deaths: 0, - critical_hits: 10, - critical_hit_accuracy: 25, - scoped_accuracy: 50, - scoped_critical_hit_accuracy: 15, - scoped_critical_hit_kills: 1, - shots_fired: 100, - shots_hit: 50, - shots_missed: 50, - scoped_shots_fired: 60, - scoped_shots_hit: 30, - weapon_accuracy: 50, - round_number: 3, - objective_index: 0, - }, - ]; - - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - (prisma.$queryRaw as unknown as ReturnType).mockResolvedValue( - mockRawStats - ); - - const oldResult = await getAllStatsForPlayer([1], "Alice"); - - prisma.scrim.findMany.mockResolvedValue(mockScrimsMaps as never); - (prisma.$queryRaw as unknown as ReturnType).mockResolvedValue( - mockRawStats - ); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe( - Effect.flatMap((svc) => svc.getAllStatsForPlayer([1], "Alice")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getAllMapWinratesForPlayer returns same winrates for Control map", async () => { - const scrimsWithDate = [ - { - id: 1, - maps: [{ mapData: [{ id: 10 }] }], - date: new Date("2024-06-01"), - }, - ]; - const matchStarts = [ - { - id: 1, - MapDataId: 10, - map_name: "Lijiang Tower", - map_type: "Control" as const, - team_1_name: "Team A", - team_2_name: "Team B", - match_time: 0, - scrimId: 1, - event_type: "match_start" as const, - round_number: 0, - objective_index: 0, - }, - ]; - const roundEnds = [ - { - id: 1, - MapDataId: 10, - round_number: 3, - match_time: 300, - scrimId: 1, - event_type: "round_end" as const, - team_1_score: 2, - team_2_score: 1, - objective_index: 0, - capturing_team: null, - }, - ]; - const playerStatsForWinrate = [ - { - id: 100, - scrimId: 1, - event_type: "player_stat", - match_time: 300, - MapDataId: 10, - player_name: "Alice", - player_team: "Team A", - player_hero: "Ana", - hero_time_played: 300, - eliminations: 5, - final_blows: 3, - deaths: 2, - all_damage_dealt: 4000, - barrier_damage_dealt: 500, - hero_damage_dealt: 3000, - healing_dealt: 8000, - healing_received: 1000, - self_healing: 200, - damage_taken: 1500, - damage_blocked: 0, - defensive_assists: 5, - offensive_assists: 10, - ultimates_earned: 3, - ultimates_used: 2, - multikill_best: 0, - multikills: 0, - solo_kills: 1, - objective_kills: 2, - environmental_kills: 0, - environmental_deaths: 0, - critical_hits: 10, - critical_hit_accuracy: 25, - scoped_accuracy: 50, - scoped_critical_hit_accuracy: 15, - scoped_critical_hit_kills: 1, - shots_fired: 100, - shots_hit: 50, - shots_missed: 50, - scoped_shots_fired: 60, - scoped_shots_hit: 30, - weapon_accuracy: 50, - round_number: 3, - objective_index: 0, - }, - ]; - - // Old DTO calls - prisma.scrim.findMany.mockResolvedValue(scrimsWithDate as never); - prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); - prisma.roundEnd.findMany.mockResolvedValue(roundEnds as never); - prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); - prisma.payloadProgress.findMany.mockResolvedValue([] as never); - prisma.pointProgress.findMany.mockResolvedValue([] as never); - prisma.playerStat.findMany.mockResolvedValue( - playerStatsForWinrate as never - ); - - const oldResult = await getAllMapWinratesForPlayer([1], "Alice"); - - // New Effect service calls (same mocks) - prisma.scrim.findMany.mockResolvedValue(scrimsWithDate as never); - prisma.matchStart.findMany.mockResolvedValue(matchStarts as never); - prisma.roundEnd.findMany.mockResolvedValue(roundEnds as never); - prisma.objectiveCaptured.findMany.mockResolvedValue([] as never); - prisma.payloadProgress.findMany.mockResolvedValue([] as never); - prisma.pointProgress.findMany.mockResolvedValue([] as never); - prisma.playerStat.findMany.mockResolvedValue( - playerStatsForWinrate as never - ); - - const testLayer = ScrimServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - ScrimService.pipe( - Effect.flatMap((svc) => svc.getAllMapWinratesForPlayer([1], "Alice")) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult.length).toBe(1); - expect(newResult[0].map).toBe("Lijiang Tower"); - // Team A won (score 2-1) and Alice is on Team A - expect(newResult[0].wins).toBe(1); - }); -}); - -// --------------------------------------------------------------------------- -// Player service parity -// --------------------------------------------------------------------------- -import { getMostPlayedHeroes } from "@/data/player-dto"; -import { PlayerService, PlayerServiceLive } from "@/data/player/player-service"; - -vi.mock("@/lib/map-data-resolver", () => ({ - resolveMapDataId: vi.fn(async (id: number) => id), -})); - -describe("Player service parity", () => { - const mockHeroStats = [ - { - player_name: "Alice", - player_team: "Team A", - player_hero: "Ana", - hero_time_played: 600, - }, - { - player_name: "Bob", - player_team: "Team A", - player_hero: "Reinhardt", - hero_time_played: 500, - }, - { - player_name: "Charlie", - player_team: "Team B", - player_hero: "Genji", - hero_time_played: 400, - }, - ]; - - it("getMostPlayedHeroes returns same results", async () => { - prisma.mapData.findFirst.mockResolvedValue({ id: 10 } as never); - prisma.playerStat.findMany.mockResolvedValue(mockHeroStats as never); - - const oldResult = await getMostPlayedHeroes(10); - - prisma.mapData.findFirst.mockResolvedValue({ id: 10 } as never); - prisma.playerStat.findMany.mockResolvedValue(mockHeroStats as never); - - const testLayer = PlayerServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - PlayerService.pipe( - Effect.flatMap((svc) => svc.getMostPlayedHeroes(10)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getMostPlayedHeroes returns empty array for empty data", async () => { - prisma.mapData.findFirst.mockResolvedValue({ id: 99 } as never); - prisma.playerStat.findMany.mockResolvedValue([] as never); - - const oldResult = await getMostPlayedHeroes(99); - - prisma.mapData.findFirst.mockResolvedValue({ id: 99 } as never); - prisma.playerStat.findMany.mockResolvedValue([] as never); - - const testLayer = PlayerServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - PlayerService.pipe( - Effect.flatMap((svc) => svc.getMostPlayedHeroes(99)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toEqual([]); - }); -}); - -// --------------------------------------------------------------------------- -// Map group service parity -// --------------------------------------------------------------------------- -import { getMapGroupsForTeam, getMapGroupById } from "@/data/map-group-dto"; -import { MapGroupService, MapGroupServiceLive } from "@/data/map/group-service"; - -describe("Map group service parity", () => { - const mockMapGroups = [ - { - id: 1, - name: "Control Maps", - description: "All control maps", - teamId: 1, - mapIds: [1, 2, 3], - category: "mode", - createdBy: "user-1", - createdAt: new Date("2024-06-01"), - updatedAt: new Date("2024-06-01"), - }, - { - id: 2, - name: "Escort Maps", - description: "All escort maps", - teamId: 1, - mapIds: [4, 5], - category: "mode", - createdBy: "user-1", - createdAt: new Date("2024-05-01"), - updatedAt: new Date("2024-05-01"), - }, - ]; - - it("getMapGroupsForTeam returns same results", async () => { - prisma.mapGroup.findMany.mockResolvedValue(mockMapGroups as never); - - const oldResult = await getMapGroupsForTeam(1); - - prisma.mapGroup.findMany.mockResolvedValue(mockMapGroups as never); - - const testLayer = MapGroupServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - MapGroupService.pipe( - Effect.flatMap((svc) => svc.getMapGroupsForTeam(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getMapGroupsForTeam returns empty array for team with no groups", async () => { - prisma.mapGroup.findMany.mockResolvedValue([] as never); - - const oldResult = await getMapGroupsForTeam(999); - - prisma.mapGroup.findMany.mockResolvedValue([] as never); - - const testLayer = MapGroupServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - MapGroupService.pipe( - Effect.flatMap((svc) => svc.getMapGroupsForTeam(999)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toEqual([]); - }); - - it("getMapGroupById returns same result", async () => { - prisma.mapGroup.findUnique.mockResolvedValue(mockMapGroups[0] as never); - - const oldResult = await getMapGroupById(1); - - prisma.mapGroup.findUnique.mockResolvedValue(mockMapGroups[0] as never); - - const testLayer = MapGroupServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - MapGroupService.pipe( - Effect.flatMap((svc) => svc.getMapGroupById(1)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - }); - - it("getMapGroupById returns null for non-existent group", async () => { - prisma.mapGroup.findUnique.mockResolvedValue(null); - - const oldResult = await getMapGroupById(999); - - prisma.mapGroup.findUnique.mockResolvedValue(null); - - const testLayer = MapGroupServiceLive.pipe( - Layer.provide( - Layer.merge( - Logger.replace(Logger.defaultLogger, TestLogger), - Layer.empty - ) - ) - ); - - const newResult = await Effect.runPromise( - MapGroupService.pipe( - Effect.flatMap((svc) => svc.getMapGroupById(999)) - ).pipe(Effect.provide(testLayer)) - ); - - expect(newResult).toEqual(oldResult); - expect(newResult).toBeNull(); - }); -}); From ec7187369b1e9108b36012c3f8da80d7e4530790 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:43:36 -0400 Subject: [PATCH 32/42] Remove legacy DTOs --- src/data/broadcast-dto.ts | 220 -- src/data/comparison-dto.ts | 1119 ---------- src/data/data-labeling-dto.ts | 216 -- src/data/heatmap-dto.ts | 274 --- src/data/hero-ban-intelligence-dto.ts | 425 ---- src/data/hero-dto.tsx | 121 - src/data/killfeed-calibration-dto.ts | 93 - src/data/killfeed-dto.ts | 273 --- src/data/map-group-dto.ts | 110 - src/data/map-intelligence-dto.ts | 413 ---- src/data/opponent-strength-dto.ts | 134 -- src/data/player-dto.tsx | 22 - src/data/player-intelligence-dto.ts | 551 ----- src/data/player-scouting-analytics-dto.ts | 1079 --------- src/data/player-scouting-dto.ts | 357 --- src/data/replay-dto.ts | 525 ----- src/data/rotation-death-dto.ts | 277 --- src/data/scouting-dto.ts | 350 --- src/data/scrim-ability-timing-dto.ts | 680 ------ src/data/scrim-dto.tsx | 373 ---- src/data/scrim-opponent-dto.ts | 588 ----- src/data/scrim-overview-dto.ts | 2428 --------------------- src/data/targets-dto.ts | 223 -- src/data/team-ability-impact-dto.ts | 441 ---- src/data/team-analytics-dto.tsx | 581 ----- src/data/team-ban-impact-dto.ts | 331 --- src/data/team-comparison-dto.ts | 613 ------ src/data/team-enemy-hero-dto.ts | 156 -- src/data/team-fight-stats-dto.tsx | 423 ---- src/data/team-hero-pool-dto.tsx | 623 ------ src/data/team-hero-swap-dto.ts | 642 ------ src/data/team-map-mode-stats-dto.tsx | 340 --- src/data/team-matchup-winrate-dto.ts | 194 -- src/data/team-performance-trends-dto.tsx | 445 ---- src/data/team-prediction-dto.ts | 138 -- src/data/team-quick-wins-dto.tsx | 412 ---- src/data/team-role-stats-dto.tsx | 646 ------ src/data/team-shared-core.ts | 192 -- src/data/team-shared-data.ts | 340 --- src/data/team-stats-dto.tsx | 480 ---- src/data/team-ult-impact-dto.ts | 308 --- src/data/team-ult-stats-dto.ts | 456 ---- src/data/tempo-dto.ts | 261 --- src/data/tournament-dto.ts | 118 - src/data/user-dto.tsx | 76 - 45 files changed, 19067 deletions(-) delete mode 100644 src/data/broadcast-dto.ts delete mode 100644 src/data/comparison-dto.ts delete mode 100644 src/data/data-labeling-dto.ts delete mode 100644 src/data/heatmap-dto.ts delete mode 100644 src/data/hero-ban-intelligence-dto.ts delete mode 100644 src/data/hero-dto.tsx delete mode 100644 src/data/killfeed-calibration-dto.ts delete mode 100644 src/data/killfeed-dto.ts delete mode 100644 src/data/map-group-dto.ts delete mode 100644 src/data/map-intelligence-dto.ts delete mode 100644 src/data/opponent-strength-dto.ts delete mode 100644 src/data/player-dto.tsx delete mode 100644 src/data/player-intelligence-dto.ts delete mode 100644 src/data/player-scouting-analytics-dto.ts delete mode 100644 src/data/player-scouting-dto.ts delete mode 100644 src/data/replay-dto.ts delete mode 100644 src/data/rotation-death-dto.ts delete mode 100644 src/data/scouting-dto.ts delete mode 100644 src/data/scrim-ability-timing-dto.ts delete mode 100644 src/data/scrim-dto.tsx delete mode 100644 src/data/scrim-opponent-dto.ts delete mode 100644 src/data/scrim-overview-dto.ts delete mode 100644 src/data/targets-dto.ts delete mode 100644 src/data/team-ability-impact-dto.ts delete mode 100644 src/data/team-analytics-dto.tsx delete mode 100644 src/data/team-ban-impact-dto.ts delete mode 100644 src/data/team-comparison-dto.ts delete mode 100644 src/data/team-enemy-hero-dto.ts delete mode 100644 src/data/team-fight-stats-dto.tsx delete mode 100644 src/data/team-hero-pool-dto.tsx delete mode 100644 src/data/team-hero-swap-dto.ts delete mode 100644 src/data/team-map-mode-stats-dto.tsx delete mode 100644 src/data/team-matchup-winrate-dto.ts delete mode 100644 src/data/team-performance-trends-dto.tsx delete mode 100644 src/data/team-prediction-dto.ts delete mode 100644 src/data/team-quick-wins-dto.tsx delete mode 100644 src/data/team-role-stats-dto.tsx delete mode 100644 src/data/team-shared-core.ts delete mode 100644 src/data/team-shared-data.ts delete mode 100644 src/data/team-stats-dto.tsx delete mode 100644 src/data/team-ult-impact-dto.ts delete mode 100644 src/data/team-ult-stats-dto.ts delete mode 100644 src/data/tempo-dto.ts delete mode 100644 src/data/tournament-dto.ts delete mode 100644 src/data/user-dto.tsx diff --git a/src/data/broadcast-dto.ts b/src/data/broadcast-dto.ts deleted file mode 100644 index 6f023ac5e..000000000 --- a/src/data/broadcast-dto.ts +++ /dev/null @@ -1,220 +0,0 @@ -import "server-only"; - -import { aggregatePlayerStats } from "@/data/comparison-dto"; -import prisma from "@/lib/prisma"; -import { removeDuplicateRows } from "@/lib/utils"; -import { type HeroName, heroRoleMapping } from "@/types/heroes"; -import { Prisma, type PlayerStat } from "@prisma/client"; - -export async function getTournamentBroadcastData(tournamentId: number) { - const tournament = await findTournamentWithRelations(tournamentId); - - if (!tournament) return null; - - const scrimIds = tournament.matches - .map((m) => m.scrimId) - .filter((id): id is number => id !== null); - - if (scrimIds.length === 0) { - return { - tournament: formatTournamentMeta(tournament), - players: [], - }; - } - - const scrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: { select: { mapData: { select: { id: true } } } } }, - }); - - const mapIds: number[] = []; - for (const scrim of scrims) { - for (const map of scrim.maps) { - for (const md of map.mapData) { - mapIds.push(md.id); - } - } - } - - if (mapIds.length === 0) { - return { - tournament: formatTournamentMeta(tournament), - players: [], - }; - } - - const allStats = removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT MAX("match_time") AS max_time, "MapDataId" - FROM "PlayerStat" - WHERE "MapDataId" IN (${Prisma.join(mapIds)}) - GROUP BY "MapDataId" - ) - SELECT ps.* FROM "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE ps."MapDataId" IN (${Prisma.join(mapIds)}) - ` - ); - - const allCalcStats = await prisma.calculatedStat.findMany({ - where: { MapDataId: { in: mapIds } }, - }); - - const playerStatsMap = new Map(); - for (const stat of allStats) { - const name = stat.player_name; - if (!playerStatsMap.has(name)) { - playerStatsMap.set(name, []); - } - playerStatsMap.get(name)!.push(stat); - } - - const playerCalcStatsMap = new Map(); - for (const stat of allCalcStats) { - const name = stat.playerName; - if (!playerCalcStatsMap.has(name)) { - playerCalcStatsMap.set(name, []); - } - playerCalcStatsMap.get(name)!.push(stat); - } - - const players = Array.from(playerStatsMap.entries()).map(([name, stats]) => { - const calcStats = playerCalcStatsMap.get(name) ?? []; - const aggregated = aggregatePlayerStats(stats, calcStats); - - const heroTimeTotals = new Map(); - for (const stat of stats) { - heroTimeTotals.set( - stat.player_hero, - (heroTimeTotals.get(stat.player_hero) ?? 0) + stat.hero_time_played - ); - } - const heroesPlayed = Array.from(heroTimeTotals.entries()) - .sort((a, b) => b[1] - a[1]) - .map(([hero]) => hero); - const primaryHero = heroesPlayed[0] ?? "Unknown"; - const role = heroRoleMapping[primaryHero as HeroName] ?? "Damage"; - - const playerTeam = stats[0]?.player_team ?? "Unknown"; - - const mapsPlayed = new Set(stats.map((s) => s.MapDataId)).size; - - return { - name, - team: playerTeam, - role, - heroesPlayed, - mapsPlayed, - stats: { - eliminations: aggregated.eliminations, - finalBlows: aggregated.finalBlows, - deaths: aggregated.deaths, - heroDamageDone: aggregated.heroDamageDealt, - healingDone: aggregated.healingDealt, - damageBlocked: aggregated.damageBlocked, - damageTaken: aggregated.damageTaken, - offensiveAssists: aggregated.offensiveAssists, - defensiveAssists: aggregated.defensiveAssists, - ultimatesUsed: aggregated.ultimatesUsed, - ultimatesEarned: aggregated.ultimatesEarned, - multikills: aggregated.multikills, - multikillBest: aggregated.multikillBest, - soloKills: aggregated.soloKills, - objectiveKills: aggregated.objectiveKills, - environmentalKills: aggregated.environmentalKills, - criticalHits: aggregated.criticalHits, - weaponAccuracy: aggregated.weaponAccuracy, - criticalHitAccuracy: aggregated.criticalHitAccuracy, - scopedAccuracy: aggregated.scopedAccuracy, - heroTimePlayed: aggregated.heroTimePlayed, - }, - per10: { - eliminationsPer10: aggregated.eliminationsPer10, - finalBlowsPer10: aggregated.finalBlowsPer10, - deathsPer10: aggregated.deathsPer10, - heroDamagePer10: aggregated.heroDamagePer10, - healingPer10: aggregated.healingDealtPer10, - damageBlockedPer10: aggregated.damageBlockedPer10, - damageTakenPer10: aggregated.damageTakenPer10, - ultimatesUsedPer10: aggregated.ultimatesUsedPer10, - soloKillsPer10: aggregated.soloKillsPer10, - offensiveAssistsPer10: aggregated.offensiveAssistsPer10, - defensiveAssistsPer10: aggregated.defensiveAssistsPer10, - objectiveKillsPer10: aggregated.objectiveKillsPer10, - }, - averages: { - avgKD: - aggregated.deaths > 0 - ? aggregated.finalBlows / aggregated.deaths - : aggregated.finalBlows, - avgMvpScore: aggregated.mvpScore, - avgFirstPickPct: aggregated.firstPickPercentage, - avgFirstDeathPct: aggregated.firstDeathPercentage, - avgDuelWinratePct: aggregated.duelWinratePercentage, - firstPickCount: aggregated.firstPickCount, - firstDeathCount: aggregated.firstDeathCount, - avgUltChargeTime: aggregated.averageUltChargeTime, - avgKillsPerUltimate: aggregated.killsPerUltimate, - }, - }; - }); - - return { - tournament: formatTournamentMeta(tournament), - players, - }; -} - -function findTournamentWithRelations(id: number) { - return prisma.tournament.findUnique({ - where: { id }, - include: { - teams: { - include: { team: { select: { id: true, name: true, image: true } } }, - orderBy: { seed: "asc" }, - }, - rounds: { orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }] }, - matches: { - include: { - team1: true, - team2: true, - winner: true, - round: true, - maps: { include: { map: true }, orderBy: { gameNumber: "asc" } }, - }, - orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], - }, - }, - }); -} - -type TournamentWithRelations = NonNullable< - Awaited> ->; - -function formatTournamentMeta(tournament: TournamentWithRelations) { - return { - id: tournament.id, - name: tournament.name, - format: tournament.format, - bestOf: tournament.bestOf, - status: tournament.status, - teams: tournament.teams.map((t) => ({ - name: t.name, - seed: t.seed, - image: t.team?.image ?? null, - eliminated: t.eliminated, - })), - matches: tournament.matches.map((m) => ({ - id: m.id, - round: m.round.roundName, - bracket: m.round.bracket, - bracketPosition: m.bracketPosition, - team1: m.team1 ? { name: m.team1.name, score: m.team1Score } : null, - team2: m.team2 ? { name: m.team2.name, score: m.team2Score } : null, - status: m.status, - winner: m.winner?.name ?? null, - })), - }; -} diff --git a/src/data/comparison-dto.ts b/src/data/comparison-dto.ts deleted file mode 100644 index ee76760af..000000000 --- a/src/data/comparison-dto.ts +++ /dev/null @@ -1,1119 +0,0 @@ -import "server-only"; - -import { - calculateMean, - calculateStandardDeviation, -} from "@/lib/distribution-utils"; -import prisma from "@/lib/prisma"; -import { removeDuplicateRows } from "@/lib/utils"; -import type { HeroName } from "@/types/heroes"; -import { - type CalculatedStat, - CalculatedStatType, - type MapType, - type PlayerStat, - Prisma, -} from "@prisma/client"; -import { cache } from "react"; - -export type AggregatedStats = { - eliminations: number; - finalBlows: number; - deaths: number; - allDamageDealt: number; - barrierDamageDealt: number; - heroDamageDealt: number; - healingDealt: number; - healingReceived: number; - selfHealing: number; - damageTaken: number; - damageBlocked: number; - defensiveAssists: number; - offensiveAssists: number; - ultimatesEarned: number; - ultimatesUsed: number; - multikillBest: number; - multikills: number; - soloKills: number; - objectiveKills: number; - environmentalKills: number; - environmentalDeaths: number; - criticalHits: number; - shotsFired: number; - shotsHit: number; - shotsMissed: number; - scopedShots: number; - scopedShotsHit: number; - scopedCriticalHitKills: number; - heroTimePlayed: number; - eliminationsPer10: number; - finalBlowsPer10: number; - deathsPer10: number; - allDamagePer10: number; - heroDamagePer10: number; - healingDealtPer10: number; - healingReceivedPer10: number; - damageTakenPer10: number; - damageBlockedPer10: number; - ultimatesEarnedPer10: number; - ultimatesUsedPer10: number; - soloKillsPer10: number; - objectiveKillsPer10: number; - defensiveAssistsPer10: number; - offensiveAssistsPer10: number; - environmentalKillsPer10: number; - environmentalDeathsPer10: number; - multikillsPer10: number; - barrierDamagePer10: number; - selfHealingPer10: number; - firstPicksPer10: number; - firstDeathsPer10: number; - mapMvpRate: number; - ajaxPer10: number; - weaponAccuracy: number; - criticalHitAccuracy: number; - scopedAccuracy: number; - scopedCriticalHitAccuracy: number; - fletaDeadliftPercentage: number; - firstPickPercentage: number; - firstPickCount: number; - firstDeathPercentage: number; - firstDeathCount: number; - mvpScore: number; - mapMvpCount: number; - ajaxCount: number; - averageUltChargeTime: number; - averageTimeToUseUlt: number; - averageDroughtTime: number; - killsPerUltimate: number; - duelWinratePercentage: number; - fightReversalPercentage: number; - eliminationsPer10StdDev: number; - deathsPer10StdDev: number; - allDamagePer10StdDev: number; - healingDealtPer10StdDev: number; - firstPickPercentageStdDev: number; - consistencyScore: number; -}; - -export type MapBreakdown = { - mapId: number; - mapDataId: number; - mapName: string; - mapType: MapType; - scrimId: number; - scrimName: string; - date: Date; - replayCode: string | null; - heroes: HeroName[]; - stats: PlayerStat & { - eliminationsPer10?: number; - deathsPer10?: number; - allDamagePer10?: number; - healingDealtPer10?: number; - damageBlockedPer10?: number; - }; - calculatedStats: CalculatedStat[]; -}; - -export type TrendsAnalysis = { - improvingMetrics: { - metric: string; - change: number; - changePercentage: number; - }[]; - decliningMetrics: { - metric: string; - change: number; - changePercentage: number; - }[]; - earlyPerformance?: AggregatedStats; - latePerformance?: AggregatedStats; -}; - -export type ComparisonStats = { - playerName: string; - filteredHeroes: HeroName[]; - mapCount: number; - mapIds: number[]; - aggregated: AggregatedStats; - perMapBreakdown: MapBreakdown[]; - trends?: TrendsAnalysis; - heroBreakdown?: Record; -}; - -function calculatePer10(value: number, timePlayed: number): number { - if (timePlayed === 0) return 0; - return (value / timePlayed) * 600; -} - -function calculatePercentage(value: number, total: number): number { - if (total === 0) return 0; - return (value / total) * 100; -} - -function aggregateCalculatedStats( - stats: CalculatedStat[] -): Partial { - const result: Partial = { - fletaDeadliftPercentage: 0, - firstPickPercentage: 0, - firstPickCount: 0, - firstDeathPercentage: 0, - firstDeathCount: 0, - mvpScore: 0, - mapMvpCount: 0, - ajaxCount: 0, - averageUltChargeTime: 0, - averageTimeToUseUlt: 0, - averageDroughtTime: 0, - killsPerUltimate: 0, - duelWinratePercentage: 0, - fightReversalPercentage: 0, - }; - - const counts: Record = {}; - - // Log the stat types we're processing - const statTypeCounts: Record = {}; - stats.forEach((stat) => { - statTypeCounts[stat.stat] = (statTypeCounts[stat.stat] ?? 0) + 1; - }); - - stats.forEach((stat) => { - switch (stat.stat) { - case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: - result.fletaDeadliftPercentage = - (result.fletaDeadliftPercentage ?? 0) + stat.value; - counts.fletaDeadlift = (counts.fletaDeadlift ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_PERCENTAGE: - result.firstPickPercentage = - (result.firstPickPercentage ?? 0) + stat.value; - counts.firstPick = (counts.firstPick ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_COUNT: - result.firstPickCount = (result.firstPickCount ?? 0) + stat.value; - break; - case CalculatedStatType.FIRST_DEATH_PERCENTAGE: - result.firstDeathPercentage = - (result.firstDeathPercentage ?? 0) + stat.value; - counts.firstDeath = (counts.firstDeath ?? 0) + 1; - break; - case CalculatedStatType.FIRST_DEATH_COUNT: - result.firstDeathCount = (result.firstDeathCount ?? 0) + stat.value; - break; - case CalculatedStatType.MVP_SCORE: - result.mvpScore = (result.mvpScore ?? 0) + stat.value; - counts.mvpScore = (counts.mvpScore ?? 0) + 1; - break; - case CalculatedStatType.MAP_MVP_COUNT: - result.mapMvpCount = (result.mapMvpCount ?? 0) + stat.value; - break; - case CalculatedStatType.AJAX_COUNT: - result.ajaxCount = (result.ajaxCount ?? 0) + stat.value; - break; - case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: - result.averageUltChargeTime = - (result.averageUltChargeTime ?? 0) + stat.value; - counts.ultCharge = (counts.ultCharge ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_TIME_TO_USE_ULT: - result.averageTimeToUseUlt = - (result.averageTimeToUseUlt ?? 0) + stat.value; - counts.timeToUseUlt = (counts.timeToUseUlt ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_DROUGHT_TIME: - result.averageDroughtTime = - (result.averageDroughtTime ?? 0) + stat.value; - counts.drought = (counts.drought ?? 0) + 1; - break; - case CalculatedStatType.KILLS_PER_ULTIMATE: - result.killsPerUltimate = (result.killsPerUltimate ?? 0) + stat.value; - counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; - break; - case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: - result.duelWinratePercentage = - (result.duelWinratePercentage ?? 0) + stat.value; - counts.duelWinrate = (counts.duelWinrate ?? 0) + 1; - break; - case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: - result.fightReversalPercentage = - (result.fightReversalPercentage ?? 0) + stat.value; - counts.fightReversal = (counts.fightReversal ?? 0) + 1; - break; - } - }); - - if (counts.fletaDeadlift) { - result.fletaDeadliftPercentage = - (result.fletaDeadliftPercentage ?? 0) / counts.fletaDeadlift; - } - if (counts.firstPick) { - result.firstPickPercentage = - (result.firstPickPercentage ?? 0) / counts.firstPick; - } - if (counts.firstDeath) { - result.firstDeathPercentage = - (result.firstDeathPercentage ?? 0) / counts.firstDeath; - } - if (counts.mvpScore) { - result.mvpScore = (result.mvpScore ?? 0) / counts.mvpScore; - } - if (counts.ultCharge) { - result.averageUltChargeTime = - (result.averageUltChargeTime ?? 0) / counts.ultCharge; - } - if (counts.timeToUseUlt) { - result.averageTimeToUseUlt = - (result.averageTimeToUseUlt ?? 0) / counts.timeToUseUlt; - } - if (counts.drought) { - result.averageDroughtTime = - (result.averageDroughtTime ?? 0) / counts.drought; - } - if (counts.killsPerUlt) { - result.killsPerUltimate = - (result.killsPerUltimate ?? 0) / counts.killsPerUlt; - } - if (counts.duelWinrate) { - result.duelWinratePercentage = - (result.duelWinratePercentage ?? 0) / counts.duelWinrate; - } - if (counts.fightReversal) { - result.fightReversalPercentage = - (result.fightReversalPercentage ?? 0) / counts.fightReversal; - } - - return result; -} - -function calculateVarianceMetrics( - perMapStats: PlayerStat[], - perMapCalculatedStats: CalculatedStat[][] -): { - eliminationsPer10StdDev: number; - deathsPer10StdDev: number; - allDamagePer10StdDev: number; - healingDealtPer10StdDev: number; - firstPickPercentageStdDev: number; - consistencyScore: number; -} { - if (perMapStats.length < 2) { - return { - eliminationsPer10StdDev: 0, - deathsPer10StdDev: 0, - allDamagePer10StdDev: 0, - healingDealtPer10StdDev: 0, - firstPickPercentageStdDev: 0, - consistencyScore: 0, - }; - } - - const eliminationsPer10Values = perMapStats.map((stat) => - calculatePer10(stat.eliminations, stat.hero_time_played) - ); - const deathsPer10Values = perMapStats.map((stat) => - calculatePer10(stat.deaths, stat.hero_time_played) - ); - const allDamagePer10Values = perMapStats.map((stat) => - calculatePer10(stat.all_damage_dealt, stat.hero_time_played) - ); - const healingDealtPer10Values = perMapStats.map((stat) => - calculatePer10(stat.healing_dealt, stat.hero_time_played) - ); - - const firstPickPercentageValues = perMapCalculatedStats - .map((stats) => { - const firstPickStat = stats.find( - (s) => s.stat === CalculatedStatType.FIRST_PICK_PERCENTAGE - ); - return firstPickStat?.value ?? 0; - }) - .filter((v) => v > 0); - - const eliminationsPer10Mean = calculateMean(eliminationsPer10Values); - const deathsPer10Mean = calculateMean(deathsPer10Values); - const allDamagePer10Mean = calculateMean(allDamagePer10Values); - const healingDealtPer10Mean = calculateMean(healingDealtPer10Values); - const firstPickPercentageMean = calculateMean(firstPickPercentageValues); - - const eliminationsPer10StdDev = calculateStandardDeviation( - eliminationsPer10Values, - eliminationsPer10Mean - ); - const deathsPer10StdDev = calculateStandardDeviation( - deathsPer10Values, - deathsPer10Mean - ); - const allDamagePer10StdDev = calculateStandardDeviation( - allDamagePer10Values, - allDamagePer10Mean - ); - const healingDealtPer10StdDev = calculateStandardDeviation( - healingDealtPer10Values, - healingDealtPer10Mean - ); - const firstPickPercentageStdDev = - firstPickPercentageValues.length > 1 - ? calculateStandardDeviation( - firstPickPercentageValues, - firstPickPercentageMean - ) - : 0; - - const coefficientOfVariations = [ - eliminationsPer10Mean > 0 - ? eliminationsPer10StdDev / eliminationsPer10Mean - : 0, - deathsPer10Mean > 0 ? deathsPer10StdDev / deathsPer10Mean : 0, - allDamagePer10Mean > 0 ? allDamagePer10StdDev / allDamagePer10Mean : 0, - healingDealtPer10Mean > 0 - ? healingDealtPer10StdDev / healingDealtPer10Mean - : 0, - ].filter((cv) => cv > 0); - - const averageCV = - coefficientOfVariations.length > 0 - ? calculateMean(coefficientOfVariations) - : 0; - - const consistencyScore = - averageCV > 0 ? Math.max(0, Math.min(100, (1 - averageCV) * 100)) : 0; - - return { - eliminationsPer10StdDev, - deathsPer10StdDev, - allDamagePer10StdDev, - healingDealtPer10StdDev, - firstPickPercentageStdDev, - consistencyScore, - }; -} - -export function aggregatePlayerStats( - stats: PlayerStat[], - calculatedStats: CalculatedStat[], - perMapStats?: PlayerStat[], - perMapCalculatedStats?: CalculatedStat[][] -): AggregatedStats { - const totals = stats.reduce( - (acc, stat) => { - acc.eliminations += stat.eliminations; - acc.finalBlows += stat.final_blows; - acc.deaths += stat.deaths; - acc.allDamageDealt += stat.all_damage_dealt; - acc.barrierDamageDealt += stat.barrier_damage_dealt; - acc.heroDamageDealt += stat.hero_damage_dealt; - acc.healingDealt += stat.healing_dealt; - acc.healingReceived += stat.healing_received; - acc.selfHealing += stat.self_healing; - acc.damageTaken += stat.damage_taken; - acc.damageBlocked += stat.damage_blocked; - acc.defensiveAssists += stat.defensive_assists; - acc.offensiveAssists += stat.offensive_assists; - acc.ultimatesEarned += stat.ultimates_earned; - acc.ultimatesUsed += stat.ultimates_used; - acc.multikillBest = Math.max(acc.multikillBest, stat.multikill_best); - acc.multikills += stat.multikills; - acc.soloKills += stat.solo_kills; - acc.objectiveKills += stat.objective_kills; - acc.environmentalKills += stat.environmental_kills; - acc.environmentalDeaths += stat.environmental_deaths; - acc.criticalHits += stat.critical_hits; - acc.shotsFired += stat.shots_fired; - acc.shotsHit += stat.shots_hit; - acc.shotsMissed += stat.shots_missed; - acc.scopedShots += stat.scoped_shots; - acc.scopedShotsHit += stat.scoped_shots_hit; - acc.scopedCriticalHitKills += stat.scoped_critical_hit_kills; - acc.heroTimePlayed += stat.hero_time_played; - return acc; - }, - { - eliminations: 0, - finalBlows: 0, - deaths: 0, - allDamageDealt: 0, - barrierDamageDealt: 0, - heroDamageDealt: 0, - healingDealt: 0, - healingReceived: 0, - selfHealing: 0, - damageTaken: 0, - damageBlocked: 0, - defensiveAssists: 0, - offensiveAssists: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - multikillBest: 0, - multikills: 0, - soloKills: 0, - objectiveKills: 0, - environmentalKills: 0, - environmentalDeaths: 0, - criticalHits: 0, - shotsFired: 0, - shotsHit: 0, - shotsMissed: 0, - scopedShots: 0, - scopedShotsHit: 0, - scopedCriticalHitKills: 0, - heroTimePlayed: 0, - } - ); - - const calculatedAggregates = aggregateCalculatedStats(calculatedStats); - - const varianceMetrics = - perMapStats && perMapCalculatedStats - ? calculateVarianceMetrics(perMapStats, perMapCalculatedStats) - : { - eliminationsPer10StdDev: 0, - deathsPer10StdDev: 0, - allDamagePer10StdDev: 0, - healingDealtPer10StdDev: 0, - firstPickPercentageStdDev: 0, - consistencyScore: 0, - }; - - return { - ...totals, - eliminationsPer10: calculatePer10( - totals.eliminations, - totals.heroTimePlayed - ), - finalBlowsPer10: calculatePer10(totals.finalBlows, totals.heroTimePlayed), - deathsPer10: calculatePer10(totals.deaths, totals.heroTimePlayed), - allDamagePer10: calculatePer10( - totals.allDamageDealt, - totals.heroTimePlayed - ), - heroDamagePer10: calculatePer10( - totals.heroDamageDealt, - totals.heroTimePlayed - ), - healingDealtPer10: calculatePer10( - totals.healingDealt, - totals.heroTimePlayed - ), - healingReceivedPer10: calculatePer10( - totals.healingReceived, - totals.heroTimePlayed - ), - damageTakenPer10: calculatePer10(totals.damageTaken, totals.heroTimePlayed), - damageBlockedPer10: calculatePer10( - totals.damageBlocked, - totals.heroTimePlayed - ), - ultimatesEarnedPer10: calculatePer10( - totals.ultimatesEarned, - totals.heroTimePlayed - ), - ultimatesUsedPer10: calculatePer10( - totals.ultimatesUsed, - totals.heroTimePlayed - ), - soloKillsPer10: calculatePer10(totals.soloKills, totals.heroTimePlayed), - objectiveKillsPer10: calculatePer10( - totals.objectiveKills, - totals.heroTimePlayed - ), - defensiveAssistsPer10: calculatePer10( - totals.defensiveAssists, - totals.heroTimePlayed - ), - offensiveAssistsPer10: calculatePer10( - totals.offensiveAssists, - totals.heroTimePlayed - ), - environmentalKillsPer10: calculatePer10( - totals.environmentalKills, - totals.heroTimePlayed - ), - environmentalDeathsPer10: calculatePer10( - totals.environmentalDeaths, - totals.heroTimePlayed - ), - multikillsPer10: calculatePer10(totals.multikills, totals.heroTimePlayed), - barrierDamagePer10: calculatePer10( - totals.barrierDamageDealt, - totals.heroTimePlayed - ), - selfHealingPer10: calculatePer10(totals.selfHealing, totals.heroTimePlayed), - firstPicksPer10: calculatePer10( - calculatedAggregates.firstPickCount ?? 0, - totals.heroTimePlayed - ), - firstDeathsPer10: calculatePer10( - calculatedAggregates.firstDeathCount ?? 0, - totals.heroTimePlayed - ), - mapMvpRate: - perMapCalculatedStats && perMapCalculatedStats.length > 0 - ? ((calculatedAggregates.mapMvpCount ?? 0) / - perMapCalculatedStats.length) * - 100 - : stats.length > 0 - ? ((calculatedAggregates.mapMvpCount ?? 0) / stats.length) * 100 - : 0, - ajaxPer10: calculatePer10( - calculatedAggregates.ajaxCount ?? 0, - totals.heroTimePlayed - ), - weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired), - criticalHitAccuracy: calculatePercentage( - totals.criticalHits, - totals.shotsHit - ), - scopedAccuracy: calculatePercentage( - totals.scopedShotsHit, - totals.scopedShots - ), - scopedCriticalHitAccuracy: calculatePercentage( - totals.scopedCriticalHitKills, - totals.scopedShotsHit - ), - fletaDeadliftPercentage: calculatedAggregates.fletaDeadliftPercentage ?? 0, - firstPickPercentage: calculatedAggregates.firstPickPercentage ?? 0, - firstPickCount: calculatedAggregates.firstPickCount ?? 0, - firstDeathPercentage: calculatedAggregates.firstDeathPercentage ?? 0, - firstDeathCount: calculatedAggregates.firstDeathCount ?? 0, - mvpScore: calculatedAggregates.mvpScore ?? 0, - mapMvpCount: calculatedAggregates.mapMvpCount ?? 0, - ajaxCount: calculatedAggregates.ajaxCount ?? 0, - averageUltChargeTime: calculatedAggregates.averageUltChargeTime ?? 0, - averageTimeToUseUlt: calculatedAggregates.averageTimeToUseUlt ?? 0, - averageDroughtTime: calculatedAggregates.averageDroughtTime ?? 0, - killsPerUltimate: calculatedAggregates.killsPerUltimate ?? 0, - duelWinratePercentage: calculatedAggregates.duelWinratePercentage ?? 0, - fightReversalPercentage: calculatedAggregates.fightReversalPercentage ?? 0, - ...varianceMetrics, - }; -} - -export function calculateTrends( - perMapStats: PlayerStat[], - perMapCalculatedStats: CalculatedStat[][] -): TrendsAnalysis { - if (perMapStats.length < 3) { - return { - improvingMetrics: [], - decliningMetrics: [], - }; - } - - const midpoint = Math.floor(perMapStats.length / 2); - const firstHalfStats = perMapStats.slice(0, midpoint); - const secondHalfStats = perMapStats.slice(midpoint); - - const firstHalfCalculated = perMapCalculatedStats.slice(0, midpoint).flat(); - const secondHalfCalculated = perMapCalculatedStats.slice(midpoint).flat(); - - const earlyPerformance = aggregatePlayerStats( - firstHalfStats, - firstHalfCalculated - ); - const latePerformance = aggregatePlayerStats( - secondHalfStats, - secondHalfCalculated - ); - - const metricComparisons = [ - { - name: "Eliminations per 10", - early: earlyPerformance.eliminationsPer10, - late: latePerformance.eliminationsPer10, - }, - { - name: "Deaths per 10", - early: earlyPerformance.deathsPer10, - late: latePerformance.deathsPer10, - invertImprovement: true, - }, - { - name: "Damage Dealt per 10", - early: earlyPerformance.heroDamagePer10, - late: latePerformance.heroDamagePer10, - }, - { - name: "Damage Taken per 10", - early: earlyPerformance.damageTakenPer10, - late: latePerformance.damageTakenPer10, - invertImprovement: true, - }, - { - name: "First Death %", - early: earlyPerformance.firstDeathPercentage, - late: latePerformance.firstDeathPercentage, - invertImprovement: true, - }, - { - name: "First Pick %", - early: earlyPerformance.firstPickPercentage, - late: latePerformance.firstPickPercentage, - }, - { - name: "MVP Score", - early: earlyPerformance.mvpScore, - late: latePerformance.mvpScore, - }, - { - name: "Fight Reversal %", - early: earlyPerformance.fightReversalPercentage, - late: latePerformance.fightReversalPercentage, - }, - { - name: "Fleta Deadlift %", - early: earlyPerformance.fletaDeadliftPercentage, - late: latePerformance.fletaDeadliftPercentage, - }, - { - name: "Kills per Ultimate", - early: earlyPerformance.killsPerUltimate, - late: latePerformance.killsPerUltimate, - }, - ]; - - const improvingMetrics: { - metric: string; - change: number; - changePercentage: number; - }[] = []; - const decliningMetrics: { - metric: string; - change: number; - changePercentage: number; - }[] = []; - - metricComparisons.forEach((comparison) => { - const change = comparison.late - comparison.early; - const changePercentage = - comparison.early !== 0 - ? ((comparison.late - comparison.early) / comparison.early) * 100 - : 0; - - const isImprovement = comparison.invertImprovement - ? change < 0 - : change > 0; - - if (Math.abs(changePercentage) > 5) { - if (isImprovement) { - improvingMetrics.push({ - metric: comparison.name, - change, - changePercentage, - }); - } else { - decliningMetrics.push({ - metric: comparison.name, - change, - changePercentage, - }); - } - } - }); - - return { - improvingMetrics, - decliningMetrics, - earlyPerformance: perMapStats.length >= 4 ? earlyPerformance : undefined, - latePerformance: perMapStats.length >= 4 ? latePerformance : undefined, - }; -} - -async function getComparisonStatsFn( - mapIds: number[], - playerName: string, - heroes?: HeroName[] -): Promise { - if (mapIds.length === 0) { - throw new Error("At least one map must be provided"); - } - - const maps = await prisma.map.findMany({ - where: { id: { in: mapIds } }, - include: { - Scrim: true, - mapData: { - include: { - match_start: true, - }, - }, - }, - }); - - const mapDataIds = maps.flatMap((map) => map.mapData.map((md) => md.id)); - - if (mapDataIds.length === 0) { - throw new Error("No map data found for the provided map IDs"); - } - - const finalRoundStats = removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "MapDataId" IN (${Prisma.join(mapDataIds)}) - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."MapDataId" IN (${Prisma.join(mapDataIds)}) - AND ps."player_name" ILIKE ${playerName} - ${heroes && heroes.length > 0 ? Prisma.sql`AND ps."player_hero" IN (${Prisma.join(heroes)})` : Prisma.empty} - ` - ); - - const calculatedStatsWhere: Prisma.CalculatedStatWhereInput = { - MapDataId: { in: mapDataIds }, - playerName: { equals: playerName, mode: "insensitive" }, - ...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}), - }; - - const calculatedStats = await prisma.calculatedStat.findMany({ - where: calculatedStatsWhere, - }); - - // Organize calculated stats by their MapDataId value - const calculatedStatsByMapDataId: Record = {}; - calculatedStats.forEach((stat) => { - if (!calculatedStatsByMapDataId[stat.MapDataId]) { - calculatedStatsByMapDataId[stat.MapDataId] = []; - } - calculatedStatsByMapDataId[stat.MapDataId].push(stat); - }); - - // Group PlayerStats by MapDataId (which stores MapData.id) - const statsByMapDataId: Record = {}; - finalRoundStats.forEach((stat) => { - if (!stat.MapDataId) return; - if (!statsByMapDataId[stat.MapDataId]) { - statsByMapDataId[stat.MapDataId] = []; - } - statsByMapDataId[stat.MapDataId].push(stat); - }); - - const perMapBreakdown: MapBreakdown[] = []; - for (const map of maps) { - // Collect PlayerStats from all MapData entries for this map - const mapStats: PlayerStat[] = []; - for (const md of map.mapData) { - const mdStats = statsByMapDataId[md.id] || []; - mapStats.push(...mdStats); - } - - // Collect CalculatedStats from all MapData entries for this map - const mapCalcStats: CalculatedStat[] = []; - for (const md of map.mapData) { - const mdStats = calculatedStatsByMapDataId[md.id] || []; - mapCalcStats.push(...mdStats); - } - - if (mapStats.length === 0) continue; - - // Use the first mapData for metadata (map name, type, etc.) - const firstMapData = map.mapData[0]; - const matchStart = firstMapData?.match_start[0]; - const heroesPlayed = Array.from( - new Set(mapStats.map((s) => s.player_hero)) - ); - - // Aggregate stats across all heroes played on this map - const aggregatedMapStats = mapStats.reduce( - (acc, stat) => ({ - eliminations: acc.eliminations + stat.eliminations, - deaths: acc.deaths + stat.deaths, - all_damage_dealt: acc.all_damage_dealt + stat.all_damage_dealt, - healing_dealt: acc.healing_dealt + stat.healing_dealt, - damage_blocked: acc.damage_blocked + stat.damage_blocked, - hero_time_played: acc.hero_time_played + stat.hero_time_played, - }), - { - eliminations: 0, - deaths: 0, - all_damage_dealt: 0, - healing_dealt: 0, - damage_blocked: 0, - hero_time_played: 0, - } - ); - - // Calculate per-10 stats for this map - const timePlayed = aggregatedMapStats.hero_time_played || 0; - const statsWithPer10 = { - ...mapStats[0], // Use first stat for non-aggregated fields - ...aggregatedMapStats, - eliminationsPer10: calculatePer10( - aggregatedMapStats.eliminations, - timePlayed - ), - deathsPer10: calculatePer10(aggregatedMapStats.deaths, timePlayed), - allDamagePer10: calculatePer10( - aggregatedMapStats.all_damage_dealt, - timePlayed - ), - healingDealtPer10: calculatePer10( - aggregatedMapStats.healing_dealt, - timePlayed - ), - damageBlockedPer10: calculatePer10( - aggregatedMapStats.damage_blocked, - timePlayed - ), - }; - - perMapBreakdown.push({ - mapId: map.id, - mapDataId: firstMapData?.id ?? 0, - mapName: matchStart?.map_name || map.name, - mapType: matchStart?.map_type || ("Control" as MapType), - scrimId: map.scrimId ?? 0, - scrimName: map.Scrim?.name ?? "Unknown", - date: map.Scrim?.date ?? map.createdAt, - replayCode: map.replayCode, - heroes: heroesPlayed as HeroName[], - stats: statsWithPer10, - calculatedStats: mapCalcStats, - }); - } - - perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); - - const aggregated = aggregatePlayerStats( - finalRoundStats, - calculatedStats, - perMapBreakdown.map((m) => m.stats), - perMapBreakdown.map((m) => m.calculatedStats) - ); - - const trends = - perMapBreakdown.length >= 3 - ? calculateTrends( - perMapBreakdown.map((m) => m.stats), - perMapBreakdown.map((m) => m.calculatedStats) - ) - : undefined; - - let heroBreakdown: Record | undefined; - if (!heroes || heroes.length > 1) { - const heroesInvolved = Array.from( - new Set(finalRoundStats.map((s) => s.player_hero)) - ); - if (heroesInvolved.length > 1) { - heroBreakdown = {}; - for (const hero of heroesInvolved) { - const heroStats = finalRoundStats.filter((s) => s.player_hero === hero); - const heroCalcStats = calculatedStats.filter((s) => s.hero === hero); - heroBreakdown[hero] = aggregatePlayerStats(heroStats, heroCalcStats); - } - } - } - - return { - playerName, - filteredHeroes: heroes ?? [], - mapCount: perMapBreakdown.length, - mapIds, - aggregated, - perMapBreakdown, - trends, - heroBreakdown, - }; -} - -export const getComparisonStats = cache(getComparisonStatsFn); - -async function getAvailableMapsForComparisonFn(params: { - teamId: number; - playerName: string; - dateFrom?: Date; - dateTo?: Date; - mapType?: MapType; - heroes?: HeroName[]; -}): Promise< - { - id: number; - name: string; - scrimId: number; - scrimName: string; - date: Date; - mapType: MapType; - replayCode: string | null; - playerHeroes: HeroName[]; - }[] -> { - const { teamId, playerName, dateFrom, dateTo, mapType, heroes } = params; - - const dateFilter = - dateFrom || dateTo - ? { - date: { - ...(dateFrom ? { gte: dateFrom } : {}), - ...(dateTo ? { lte: dateTo } : {}), - }, - } - : {}; - - const scrims = await prisma.scrim.findMany({ - where: { - teamId, - ...dateFilter, - }, - include: { - maps: { - include: { - mapData: { - include: { - match_start: true, - player_stat: { - where: { - player_name: { equals: playerName, mode: "insensitive" }, - ...(heroes && heroes.length > 0 - ? { player_hero: { in: heroes } } - : {}), - }, - }, - }, - }, - }, - }, - }, - }); - - const availableMaps: { - id: number; - name: string; - scrimId: number; - scrimName: string; - date: Date; - mapType: MapType; - replayCode: string | null; - playerHeroes: HeroName[]; - }[] = []; - - for (const scrim of scrims) { - for (const map of scrim.maps) { - const playerStats = map.mapData.flatMap((md) => md.player_stat); - if (playerStats.length === 0) continue; - - const matchStart = map.mapData[0]?.match_start[0]; - if (!matchStart) continue; - - if (mapType && matchStart.map_type !== mapType) continue; - - const heroesPlayed = Array.from( - new Set(playerStats.map((s) => s.player_hero)) - ) as HeroName[]; - - availableMaps.push({ - id: map.id, - name: matchStart.map_name, - scrimId: scrim.id, - scrimName: scrim.name, - date: scrim.date, - mapType: matchStart.map_type, - replayCode: map.replayCode, - playerHeroes: heroesPlayed, - }); - } - } - - availableMaps.sort((a, b) => b.date.getTime() - a.date.getTime()); - - return availableMaps; -} - -export const getAvailableMapsForComparison = cache( - getAvailableMapsForComparisonFn -); - -async function getTeamPlayersFn( - teamId: number, - mapIds?: number[] -): Promise<{ name: string; mapCount: number }[]> { - if (mapIds && mapIds.length > 0) { - const maps = await prisma.map.findMany({ - where: { - id: { in: mapIds }, - Scrim: { teamId }, - }, - select: { - mapData: { - select: { - player_stat: { - select: { player_name: true }, - distinct: ["player_name"], - }, - }, - }, - }, - }); - - const playerMapCounts = new Map(); - for (const map of maps) { - for (const mapData of map.mapData) { - for (const stat of mapData.player_stat) { - const currentCount = playerMapCounts.get(stat.player_name) ?? 0; - playerMapCounts.set(stat.player_name, currentCount + 1); - } - } - } - - const players = Array.from(playerMapCounts.entries()) - .map(([name, mapCount]) => ({ name, mapCount })) - .sort((a, b) => a.name.localeCompare(b.name)); - - return players; - } - - const scrims = await prisma.scrim.findMany({ - where: { teamId }, - select: { - maps: { - select: { - mapData: { - select: { - player_stat: { - select: { - player_name: true, - }, - distinct: ["player_name"], - }, - }, - }, - }, - }, - }, - }); - - const playerMapCounts = new Map(); - - for (const scrim of scrims) { - for (const map of scrim.maps) { - for (const mapData of map.mapData) { - for (const stat of mapData.player_stat) { - const currentCount = playerMapCounts.get(stat.player_name) ?? 0; - playerMapCounts.set(stat.player_name, currentCount + 1); - } - } - } - } - - const players = Array.from(playerMapCounts.entries()) - .map(([name, mapCount]) => ({ name, mapCount })) - .sort((a, b) => a.name.localeCompare(b.name)); - - return players; -} - -export const getTeamPlayers = cache(getTeamPlayersFn); diff --git a/src/data/data-labeling-dto.ts b/src/data/data-labeling-dto.ts deleted file mode 100644 index 46f233ecc..000000000 --- a/src/data/data-labeling-dto.ts +++ /dev/null @@ -1,216 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { MapType, RosterRole } from "@prisma/client"; -import { cache } from "react"; - -export type UnlabeledMatchSummary = { - id: number; - team1: string; - team1FullName: string; - team2: string; - team2FullName: string; - team1Score: number | null; - team2Score: number | null; - matchDate: Date; - tournament: string; - vodCount: number; - labeledMaps: number; - totalMaps: number; -}; - -export type UnlabeledMatchesResult = { - matches: UnlabeledMatchSummary[]; - totalCount: number; - page: number; - pageSize: number; -}; - -async function getUnlabeledMatchesFn( - page: number, - pageSize: number -): Promise { - const where = { - NOT: { vods: { equals: "[]" as unknown as undefined } }, - maps: { - some: { - OR: [ - { team1Comp: { isEmpty: true } }, - { team2Comp: { isEmpty: true } }, - ], - }, - }, - }; - - const [matches, totalCount] = await Promise.all([ - prisma.scoutingMatch.findMany({ - where, - include: { - maps: { select: { id: true, team1Comp: true, team2Comp: true } }, - tournament: { select: { title: true } }, - }, - orderBy: { matchDate: "desc" }, - skip: page * pageSize, - take: pageSize, - }), - prisma.scoutingMatch.count({ where }), - ]); - - return { - matches: matches.map((m) => { - const vods = m.vods as { url: string; platform: string }[]; - const labeledMaps = m.maps.filter( - (map) => map.team1Comp.length > 0 && map.team2Comp.length > 0 - ).length; - - return { - id: m.id, - team1: m.team1, - team1FullName: m.team1FullName, - team2: m.team2, - team2FullName: m.team2FullName, - team1Score: m.team1Score, - team2Score: m.team2Score, - matchDate: m.matchDate, - tournament: m.tournament.title, - vodCount: vods.length, - labeledMaps, - totalMaps: m.maps.length, - }; - }), - totalCount, - page, - pageSize, - }; -} - -export const getUnlabeledMatches = cache(getUnlabeledMatchesFn); - -export type RosterPlayerForLabeling = { - id: number; - displayName: string; - role: RosterRole; -}; - -export type HeroAssignmentForLabeling = { - heroName: string; - playerName: string; - team: string; -}; - -export type MatchForLabeling = { - id: number; - team1: string; - team1FullName: string; - team2: string; - team2FullName: string; - team1Score: number | null; - team2Score: number | null; - matchDate: Date; - tournament: string; - vods: { url: string; platform: string }[]; - maps: MatchMapForLabeling[]; - team1Roster: RosterPlayerForLabeling[]; - team2Roster: RosterPlayerForLabeling[]; -}; - -export type MatchMapForLabeling = { - id: number; - gameNumber: number; - mapType: MapType; - mapName: string; - team1Score: string; - team2Score: string; - winner: string; - team1Comp: string[]; - team2Comp: string[]; - heroBans: { - id: number; - team: string; - hero: string; - banOrder: number; - }[]; - heroAssignments: HeroAssignmentForLabeling[]; -}; - -async function getRosterPlayers( - tournamentId: number, - teamFullName: string -): Promise { - const roster = await prisma.scoutingRoster.findUnique({ - where: { - tournamentId_teamName: { tournamentId, teamName: teamFullName }, - }, - include: { - players: { - where: { category: { in: ["player", "substitute"] } }, - select: { id: true, displayName: true, role: true }, - orderBy: { id: "asc" }, - }, - }, - }); - - return roster?.players ?? []; -} - -async function getMatchForLabelingFn( - matchId: number -): Promise { - const match = await prisma.scoutingMatch.findUnique({ - where: { id: matchId }, - include: { - maps: { - include: { - heroBans: { - select: { id: true, team: true, hero: true, banOrder: true }, - orderBy: { banOrder: "asc" }, - }, - heroAssignments: { - select: { heroName: true, playerName: true, team: true }, - }, - }, - orderBy: { gameNumber: "asc" }, - }, - tournament: { select: { title: true, id: true } }, - }, - }); - - if (!match) return null; - - const vods = match.vods as { url: string; platform: string }[]; - - const [team1Roster, team2Roster] = await Promise.all([ - getRosterPlayers(match.tournament.id, match.team1FullName), - getRosterPlayers(match.tournament.id, match.team2FullName), - ]); - - return { - id: match.id, - team1: match.team1, - team1FullName: match.team1FullName, - team2: match.team2, - team2FullName: match.team2FullName, - team1Score: match.team1Score, - team2Score: match.team2Score, - matchDate: match.matchDate, - tournament: match.tournament.title, - vods, - team1Roster, - team2Roster, - maps: match.maps.map((map) => ({ - id: map.id, - gameNumber: map.gameNumber, - mapType: map.mapType, - mapName: map.mapName, - team1Score: map.team1Score, - team2Score: map.team2Score, - winner: map.winner, - team1Comp: map.team1Comp, - team2Comp: map.team2Comp, - heroBans: map.heroBans, - heroAssignments: map.heroAssignments, - })), - }; -} - -export const getMatchForLabeling = cache(getMatchForLabelingFn); diff --git a/src/data/heatmap-dto.ts b/src/data/heatmap-dto.ts deleted file mode 100644 index c8e5c84d1..000000000 --- a/src/data/heatmap-dto.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { - getControlSubMapName, - getControlSubMapNames, - isControlMap, -} from "@/lib/map-calibration/control-map-index"; -import { loadCalibration } from "@/lib/map-calibration/load-calibration"; -import type { MapTransform } from "@/lib/map-calibration/types"; -import { worldToImage } from "@/lib/map-calibration/world-to-image"; -import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; - -type Point = { u: number; v: number }; -export type KillPoint = Point & { - team: 1 | 2; - attackerName: string; - attackerHero: string; - victimName: string; - victimHero: string; - ability: string; - matchTime: number; -}; - -export type HeatmapSubMap = { - subMapName: string; - calibrationMapName: string; - imagePresignedUrl: string; - imageWidth: number; - imageHeight: number; - damagePoints: Point[]; - healingPoints: Point[]; - killPoints: KillPoint[]; -}; - -export type HeatmapData = - | { type: "single"; subMap: HeatmapSubMap } - | { type: "control"; subMaps: HeatmapSubMap[] } - | { type: "no_calibration" } - | { type: "no_coordinates" }; - -function toImagePoint( - x: number | null, - z: number | null, - transform: MapTransform -): Point | null { - if (x == null || z == null) return null; - return worldToImage({ x, y: z }, transform); -} - -type TimedCoord = { match_time: number; x: number | null; z: number | null }; -type TimedKillCoord = TimedCoord & { - victim_team: string; - attacker_name: string; - attacker_hero: string; - victim_name: string; - victim_hero: string; - event_ability: string; -}; - -type EventsByCategory = { - damage: TimedCoord[]; - healing: TimedCoord[]; - kills: TimedKillCoord[]; -}; - -async function fetchEvents(mapDataId: number): Promise { - const [damageRows, healingRows, killRows] = await Promise.all([ - prisma.damage.findMany({ - where: { MapDataId: mapDataId }, - select: { match_time: true, victim_x: true, victim_z: true }, - }), - prisma.healing.findMany({ - where: { MapDataId: mapDataId }, - select: { match_time: true, healer_x: true, healer_z: true }, - }), - prisma.kill.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - victim_x: true, - victim_z: true, - victim_team: true, - attacker_name: true, - attacker_hero: true, - victim_name: true, - victim_hero: true, - event_ability: true, - }, - }), - ]); - - return { - damage: damageRows.map((r) => ({ - match_time: r.match_time, - x: r.victim_x, - z: r.victim_z, - })), - healing: healingRows.map((r) => ({ - match_time: r.match_time, - x: r.healer_x, - z: r.healer_z, - })), - kills: killRows.map((r) => ({ - match_time: r.match_time, - x: r.victim_x, - z: r.victim_z, - victim_team: r.victim_team, - attacker_name: r.attacker_name, - attacker_hero: r.attacker_hero, - victim_name: r.victim_name, - victim_hero: r.victim_hero, - event_ability: r.event_ability, - })), - }; -} - -function convertPoints(events: TimedCoord[], transform: MapTransform): Point[] { - const result: Point[] = []; - for (const e of events) { - const p = toImagePoint(e.x, e.z, transform); - if (p) result.push(p); - } - return result; -} - -function convertKillPoints( - events: TimedKillCoord[], - transform: MapTransform, - team1Name: string -): KillPoint[] { - const result: KillPoint[] = []; - for (const e of events) { - const p = toImagePoint(e.x, e.z, transform); - if (p) { - result.push({ - ...p, - team: e.victim_team === team1Name ? 1 : 2, - attackerName: e.attacker_name, - attackerHero: e.attacker_hero, - victimName: e.victim_name, - victimHero: e.victim_hero, - ability: e.event_ability, - matchTime: e.match_time, - }); - } - } - return result; -} - -function buildSubMap( - calibrationMapName: string, - cal: NonNullable>>, - events: EventsByCategory, - team1Name: string -): HeatmapSubMap { - const colonIdx = calibrationMapName.indexOf(": "); - const displayName = - colonIdx >= 0 ? calibrationMapName.slice(colonIdx + 2) : calibrationMapName; - - return { - subMapName: displayName, - calibrationMapName, - imagePresignedUrl: cal.imagePresignedUrl, - imageWidth: cal.imageWidth, - imageHeight: cal.imageHeight, - damagePoints: convertPoints(events.damage, cal.transform), - healingPoints: convertPoints(events.healing, cal.transform), - killPoints: convertKillPoints(events.kills, cal.transform, team1Name), - }; -} - -export async function getHeatmapData(mapDataId: number): Promise { - const matchStart = await prisma.matchStart.findFirst({ - where: { MapDataId: mapDataId }, - select: { map_name: true, map_type: true, team_1_name: true }, - }); - - if (!matchStart) return { type: "no_calibration" }; - - const events = await fetchEvents(mapDataId); - - const hasCoords = - events.damage.some((e) => e.x != null && e.z != null) || - events.healing.some((e) => e.x != null && e.z != null) || - events.kills.some((e) => e.x != null && e.z != null); - - if (!hasCoords) return { type: "no_coordinates" }; - - if ( - matchStart.map_type === $Enums.MapType.Control && - isControlMap(matchStart.map_name) - ) { - return getControlHeatmapData( - matchStart.map_name, - mapDataId, - events, - matchStart.team_1_name - ); - } - - const cal = await loadCalibration(matchStart.map_name); - if (!cal) return { type: "no_calibration" }; - - return { - type: "single", - subMap: buildSubMap( - matchStart.map_name, - cal, - events, - matchStart.team_1_name - ), - }; -} - -function assignToRound( - matchTime: number, - roundStarts: { match_time: number; objective_index: number }[] -): number { - for (let i = roundStarts.length - 1; i >= 0; i--) { - if (matchTime >= roundStarts[i].match_time) { - return roundStarts[i].objective_index; - } - } - return 0; -} - -async function getControlHeatmapData( - mapName: string, - mapDataId: number, - events: EventsByCategory, - team1Name: string -): Promise { - const roundStarts = await prisma.roundStart.findMany({ - where: { MapDataId: mapDataId }, - select: { match_time: true, objective_index: true }, - orderBy: { match_time: "asc" }, - }); - - function splitBySubMap(coords: TimedCoord[]): Map { - const result = new Map(); - for (const c of coords) { - const idx = assignToRound(c.match_time, roundStarts); - const name = getControlSubMapName(mapName, idx); - if (!name) continue; - const arr = result.get(name) ?? []; - arr.push(c); - result.set(name, arr); - } - return result; - } - - const damageBySubMap = splitBySubMap(events.damage); - const healingBySubMap = splitBySubMap(events.healing); - const killsBySubMap = splitBySubMap(events.kills); - - const allSubMapNames = getControlSubMapNames(mapName); - const subMaps: HeatmapSubMap[] = []; - - for (const calibrationMapName of allSubMapNames) { - const cal = await loadCalibration(calibrationMapName); - if (!cal) continue; - - const subEvents: EventsByCategory = { - damage: damageBySubMap.get(calibrationMapName) ?? [], - healing: healingBySubMap.get(calibrationMapName) ?? [], - kills: (killsBySubMap.get(calibrationMapName) ?? []) as TimedKillCoord[], - }; - - subMaps.push(buildSubMap(calibrationMapName, cal, subEvents, team1Name)); - } - - if (subMaps.length === 0) return { type: "no_calibration" }; - - return { type: "control", subMaps }; -} diff --git a/src/data/hero-ban-intelligence-dto.ts b/src/data/hero-ban-intelligence-dto.ts deleted file mode 100644 index 852e9a713..000000000 --- a/src/data/hero-ban-intelligence-dto.ts +++ /dev/null @@ -1,425 +0,0 @@ -import "server-only"; - -import { assessConfidence, type ConfidenceMetadata } from "@/lib/confidence"; -import { - hasScrimData, - SCRIM_CONFIDENCE_THRESHOLDS, - type DataAvailabilityProfile, -} from "@/lib/data-availability"; -import type { MapType } from "@prisma/client"; -import { cache } from "react"; -import { getBaseTeamData } from "./team-shared-data"; -import { getOpponentMatchData } from "./scouting-dto"; -import { getOpponentScrimHeroBans } from "./scrim-opponent-dto"; - -export type HeroWinRateDelta = { - hero: string; - winRateWhenAvailable: number; - winRateWhenBanned: number; - delta: number; - mapsAvailable: number; - mapsBanned: number; - confidenceAvailable: ConfidenceMetadata; - confidenceBanned: ConfidenceMetadata; -}; - -export type ComfortCrutch = { - hero: string; - banFrequency: number; - totalMaps: number; - banRate: number; - winRateDelta: number; - crutchScore: number; - confidence: ConfidenceMetadata; -}; - -export type ProtectedHero = { - hero: string; - timesBannedByTeam: number; - totalMaps: number; - banRate: number; -}; - -export type BanRateByMapType = { - hero: string; - mapType: MapType; - banCount: number; - totalMapsOfType: number; - banRate: number; -}; - -export type HeroExposure = { - hero: string; - userPlayRate: number; - userTimePlayed: number; - opponentBanRate: number; - opponentBanCount: number; - exposureRisk: "high" | "medium" | "low"; -}; - -export type BanDisruptionEntry = { - hero: string; - winRateDelta: number; - mapsAvailable: number; - mapsBanned: number; - disruptionScore: number; - confidence: ConfidenceMetadata; -}; - -export type HeroBanIntelligence = { - winRateDeltas: HeroWinRateDelta[]; - comfortCrutches: ComfortCrutch[]; - protectedHeroes: ProtectedHero[]; - banRateByMapType: BanRateByMapType[]; - heroExposure: HeroExposure[]; - banDisruptionRanking: BanDisruptionEntry[]; -}; - -type MapWithBans = { - mapName: string; - mapType: MapType; - matchDate: Date; - teamSide: "team1" | "team2"; - won: boolean; - heroBans: { team: string; hero: string }[]; - source: "owcs" | "scrim"; -}; - -async function getOpponentMapBanData(teamAbbr: string): Promise { - const matches = await getOpponentMatchData(teamAbbr); - - const results: MapWithBans[] = []; - for (const match of matches) { - const teamSide = - match.team1 === teamAbbr ? ("team1" as const) : ("team2" as const); - for (const map of match.maps) { - results.push({ - mapName: map.mapName, - mapType: map.mapType, - matchDate: match.matchDate, - teamSide, - won: map.winner === teamSide, - heroBans: map.heroBans.map((b) => ({ team: b.team, hero: b.hero })), - source: "owcs", - }); - } - } - return results; -} - -function computeWinRateDeltas( - maps: MapWithBans[], - includesScrimData: boolean -): HeroWinRateDelta[] { - const confidenceThresholds = includesScrimData - ? SCRIM_CONFIDENCE_THRESHOLDS - : undefined; - - const totalMaps = maps.length; - const totalWins = maps.filter((m) => m.won).length; - - const bannedAccum = new Map< - string, - { bannedTotal: number; bannedWins: number } - >(); - - for (const map of maps) { - for (const ban of map.heroBans) { - let accum = bannedAccum.get(ban.hero); - if (!accum) { - accum = { bannedTotal: 0, bannedWins: 0 }; - bannedAccum.set(ban.hero, accum); - } - accum.bannedTotal++; - if (map.won) accum.bannedWins++; - } - } - - return Array.from(bannedAccum.entries()) - .map(([hero, accum]) => { - const availableTotal = totalMaps - accum.bannedTotal; - const availableWins = totalWins - accum.bannedWins; - - const winRateWhenAvailable = - availableTotal > 0 ? (availableWins / availableTotal) * 100 : 0; - const winRateWhenBanned = - accum.bannedTotal > 0 - ? (accum.bannedWins / accum.bannedTotal) * 100 - : 0; - - return { - hero, - winRateWhenAvailable, - winRateWhenBanned, - delta: winRateWhenAvailable - winRateWhenBanned, - mapsAvailable: availableTotal, - mapsBanned: accum.bannedTotal, - confidenceAvailable: assessConfidence( - availableTotal, - confidenceThresholds - ), - confidenceBanned: assessConfidence( - accum.bannedTotal, - confidenceThresholds - ), - }; - }) - .sort((a, b) => b.delta - a.delta); -} - -function computeComfortCrutches( - maps: MapWithBans[], - winRateDeltas: HeroWinRateDelta[], - includesScrimData: boolean -): ComfortCrutch[] { - const confidenceThresholds = includesScrimData - ? SCRIM_CONFIDENCE_THRESHOLDS - : undefined; - const totalMaps = maps.length; - - const banCounts = new Map(); - for (const map of maps) { - for (const ban of map.heroBans) { - banCounts.set(ban.hero, (banCounts.get(ban.hero) ?? 0) + 1); - } - } - - const deltaMap = new Map(winRateDeltas.map((d) => [d.hero, d])); - - return Array.from(banCounts.entries()) - .map(([hero, banFrequency]) => { - const banRate = totalMaps > 0 ? (banFrequency / totalMaps) * 100 : 0; - const wrDelta = deltaMap.get(hero); - const winRateDelta = wrDelta?.delta ?? 0; - - // Crutch score = ban rate * WR delta (higher = more exploitable) - // Normalized: ban rate as fraction * delta as fraction - const crutchScore = (banRate / 100) * Math.max(0, winRateDelta); - - return { - hero, - banFrequency, - totalMaps, - banRate, - winRateDelta, - crutchScore, - confidence: assessConfidence(banFrequency, confidenceThresholds), - }; - }) - .filter((c) => c.winRateDelta > 0) - .sort((a, b) => b.crutchScore - a.crutchScore); -} - -function computeProtectedHeroes(maps: MapWithBans[]): ProtectedHero[] { - const totalMaps = maps.length; - const bansByTeam = new Map(); - - for (const map of maps) { - for (const ban of map.heroBans) { - if (ban.team === map.teamSide) { - bansByTeam.set(ban.hero, (bansByTeam.get(ban.hero) ?? 0) + 1); - } - } - } - - return Array.from(bansByTeam.entries()) - .map(([hero, timesBannedByTeam]) => ({ - hero, - timesBannedByTeam, - totalMaps, - banRate: totalMaps > 0 ? (timesBannedByTeam / totalMaps) * 100 : 0, - })) - .sort((a, b) => a.banRate - b.banRate) - .filter((h) => h.banRate < 5); -} - -function computeBanRateByMapType(maps: MapWithBans[]): BanRateByMapType[] { - const mapTypeCount = new Map(); - const heroBansByType = new Map>(); - - for (const map of maps) { - mapTypeCount.set(map.mapType, (mapTypeCount.get(map.mapType) ?? 0) + 1); - - for (const ban of map.heroBans) { - let heroTypes = heroBansByType.get(ban.hero); - if (!heroTypes) { - heroTypes = new Map(); - heroBansByType.set(ban.hero, heroTypes); - } - heroTypes.set(map.mapType, (heroTypes.get(map.mapType) ?? 0) + 1); - } - } - - const results: BanRateByMapType[] = []; - for (const [hero, typeMap] of heroBansByType) { - for (const [mapType, banCount] of typeMap) { - const totalMapsOfType = mapTypeCount.get(mapType) ?? 0; - results.push({ - hero, - mapType, - banCount, - totalMapsOfType, - banRate: totalMapsOfType > 0 ? (banCount / totalMapsOfType) * 100 : 0, - }); - } - } - - return results.sort((a, b) => b.banRate - a.banRate); -} - -async function computeHeroExposure( - maps: MapWithBans[], - userTeamId: number -): Promise { - const baseData = await getBaseTeamData(userTeamId); - const { allPlayerStats, teamRosterSet } = baseData; - - const heroTimePlayed = new Map(); - let totalTimePlayed = 0; - - for (const stat of allPlayerStats) { - if (!teamRosterSet.has(stat.player_name)) continue; - const current = heroTimePlayed.get(stat.player_hero) ?? 0; - heroTimePlayed.set(stat.player_hero, current + stat.hero_time_played); - totalTimePlayed += stat.hero_time_played; - } - - const totalOpponentMaps = maps.length; - const opponentBanCounts = new Map(); - for (const map of maps) { - const opponentSide = map.teamSide === "team1" ? "team2" : "team1"; - for (const ban of map.heroBans) { - if (ban.team === opponentSide) { - opponentBanCounts.set( - ban.hero, - (opponentBanCounts.get(ban.hero) ?? 0) + 1 - ); - } - } - } - - const exposures: HeroExposure[] = []; - for (const [hero, timePlayed] of heroTimePlayed) { - const playRate = - totalTimePlayed > 0 ? (timePlayed / totalTimePlayed) * 100 : 0; - if (playRate < 1) continue; - - const opponentBanCount = opponentBanCounts.get(hero) ?? 0; - const opponentBanRate = - totalOpponentMaps > 0 ? (opponentBanCount / totalOpponentMaps) * 100 : 0; - - const HIGH_BAN_THRESHOLD = 30; - const MEDIUM_BAN_THRESHOLD = 15; - const exposureRisk: HeroExposure["exposureRisk"] = - opponentBanRate >= HIGH_BAN_THRESHOLD && playRate >= 10 - ? "high" - : opponentBanRate >= MEDIUM_BAN_THRESHOLD && playRate >= 5 - ? "medium" - : "low"; - - exposures.push({ - hero, - userPlayRate: playRate, - userTimePlayed: timePlayed, - opponentBanRate, - opponentBanCount, - exposureRisk, - }); - } - - return exposures - .filter((e) => e.opponentBanCount > 0) - .sort((a, b) => { - const riskOrder = { high: 0, medium: 1, low: 2 }; - if (riskOrder[a.exposureRisk] !== riskOrder[b.exposureRisk]) { - return riskOrder[a.exposureRisk] - riskOrder[b.exposureRisk]; - } - return b.userPlayRate - a.userPlayRate; - }); -} - -function computeBanDisruptionRanking( - winRateDeltas: HeroWinRateDelta[], - includesScrimData: boolean -): BanDisruptionEntry[] { - const confidenceThresholds = includesScrimData - ? SCRIM_CONFIDENCE_THRESHOLDS - : undefined; - - return winRateDeltas - .filter((d) => d.mapsBanned >= 2) - .map((d) => { - // Disruption score weights the WR delta by the confidence of the ban sample. - // A large delta from 2 maps is less reliable than a moderate delta from 10. - const sampleWeight = Math.min(d.mapsBanned / 10, 1); - const disruptionScore = d.delta * sampleWeight; - - return { - hero: d.hero, - winRateDelta: d.delta, - mapsAvailable: d.mapsAvailable, - mapsBanned: d.mapsBanned, - disruptionScore, - confidence: assessConfidence(d.mapsBanned, confidenceThresholds), - }; - }) - .sort((a, b) => b.disruptionScore - a.disruptionScore); -} - -async function getHeroBanIntelligenceFn( - opponentAbbr: string, - userTeamId: number | null, - profile?: DataAvailabilityProfile -): Promise { - const owcsMaps = await getOpponentMapBanData(opponentAbbr); - - const includesScrimData = !!profile && !!userTeamId && hasScrimData(profile); - - let maps: MapWithBans[] = owcsMaps; - - if (includesScrimData && userTeamId !== null) { - const scrimBans = await getOpponentScrimHeroBans(userTeamId, opponentAbbr); - const scrimMaps: MapWithBans[] = scrimBans.map((s) => ({ - mapName: s.mapName, - mapType: s.mapType, - matchDate: s.scrimDate, - teamSide: "team2" as const, - won: !s.opponentWon, - heroBans: s.opponentBans.map((hero) => ({ - team: "team2", - hero, - })), - source: "scrim" as const, - })); - maps = [...owcsMaps, ...scrimMaps]; - } - - const winRateDeltas = computeWinRateDeltas(maps, includesScrimData); - const comfortCrutches = computeComfortCrutches( - maps, - winRateDeltas, - includesScrimData - ); - const protectedHeroes = computeProtectedHeroes(maps); - const banRateByMapType = computeBanRateByMapType(maps); - const banDisruptionRanking = computeBanDisruptionRanking( - winRateDeltas, - includesScrimData - ); - - let heroExposure: HeroExposure[] = []; - if (userTeamId !== null) { - heroExposure = await computeHeroExposure(maps, userTeamId); - } - - return { - winRateDeltas, - comfortCrutches, - protectedHeroes, - banRateByMapType, - heroExposure, - banDisruptionRanking, - }; -} - -export const getHeroBanIntelligence = cache(getHeroBanIntelligenceFn); diff --git a/src/data/hero-dto.tsx b/src/data/hero-dto.tsx deleted file mode 100644 index 10844fc40..000000000 --- a/src/data/hero-dto.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { removeDuplicateRows } from "@/lib/utils"; -import { type PlayerStat, Prisma } from "@prisma/client"; -import { cache } from "react"; - -async function getAllStatsForHeroFn(scrimIds: number[], hero: string) { - const mapDataIds = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: true }, - }); - - const mapDataIdSet = new Set(); - mapDataIds.forEach((scrim) => { - scrim.maps.forEach((map) => { - mapDataIdSet.add(map.id); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "MapDataId" IN (${Prisma.join(mapDataIdArray)}) - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."MapDataId" IN (${Prisma.join(mapDataIdArray)}) - AND ps."player_hero" ILIKE ${hero}` - ); -} - -/** - * Returns all of the statistics for a specific hero. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the hero participated in. - * @param hero The name of the hero. - * @returns The statistics for the specified hero. - */ -export const getAllStatsForHero = cache(getAllStatsForHeroFn); - -async function getAllKillsForHeroFn(scrimIds: number[], hero: string) { - const mapDataIds = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: true }, - }); - - const mapDataIdSet = new Set(); - mapDataIds.forEach((scrim) => { - scrim.maps.forEach((map) => { - mapDataIdSet.add(map.id); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return await prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIdArray }, - attacker_hero: { equals: hero, mode: "insensitive" }, - }, - }); -} - -/** - * Returns all of the kills for a specific hero. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the hero participated in. - * @param playerName The name of the player. - * @returns The kills for the specified player. - */ -export const getAllKillsForHero = cache(getAllKillsForHeroFn); - -async function getAllDeathsForHeroFn(scrimIds: number[], hero: string) { - const mapDataIds = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: true }, - }); - - const mapDataIdSet = new Set(); - mapDataIds.forEach((scrim) => { - scrim.maps.forEach((map) => { - mapDataIdSet.add(map.id); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return await prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIdArray }, - victim_hero: { equals: hero, mode: "insensitive" }, - }, - }); -} - -/** - * Returns all of the deaths for a specific hero. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the hero participated in. - * @param hero The name of the hero. - * @returns The deaths for the specified hero. - */ -export const getAllDeathsForHero = cache(getAllDeathsForHeroFn); diff --git a/src/data/killfeed-calibration-dto.ts b/src/data/killfeed-calibration-dto.ts deleted file mode 100644 index f33a63b70..000000000 --- a/src/data/killfeed-calibration-dto.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - getControlSubMapName, - isControlMap, -} from "@/lib/map-calibration/control-map-index"; -import { - loadCalibration, - type LoadedCalibration, -} from "@/lib/map-calibration/load-calibration"; -import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; - -export type KillfeedCalibrationData = { - calibrations: Map; - mapName: string; - mapType: $Enums.MapType; - roundStarts: { match_time: number; objective_index: number }[]; -}; - -export async function getKillfeedCalibration( - mapDataId: number -): Promise { - const matchStart = await prisma.matchStart.findFirst({ - where: { MapDataId: mapDataId }, - select: { map_name: true, map_type: true }, - }); - - if (!matchStart) return null; - - const calibrations = new Map(); - - if ( - matchStart.map_type === $Enums.MapType.Control && - isControlMap(matchStart.map_name) - ) { - const roundStarts = await prisma.roundStart.findMany({ - where: { MapDataId: mapDataId }, - select: { match_time: true, objective_index: true }, - orderBy: { match_time: "asc" }, - }); - - const seenIndices = new Set(); - for (const rs of roundStarts) { - seenIndices.add(rs.objective_index); - } - - for (const idx of seenIndices) { - const subMapName = getControlSubMapName(matchStart.map_name, idx); - if (!subMapName) continue; - const cal = await loadCalibration(subMapName); - if (cal) calibrations.set(subMapName, cal); - } - - return { - calibrations, - mapName: matchStart.map_name, - mapType: matchStart.map_type, - roundStarts, - }; - } - - const cal = await loadCalibration(matchStart.map_name); - if (cal) calibrations.set(matchStart.map_name, cal); - - return { - calibrations, - mapName: matchStart.map_name, - mapType: matchStart.map_type, - roundStarts: [], - }; -} - -export type SerializedCalibrationData = { - calibrations: Record; - mapName: string; - mapType: string; - roundStarts: { match_time: number; objective_index: number }[]; -} | null; - -export function serializeCalibrationData( - data: KillfeedCalibrationData | null -): SerializedCalibrationData { - if (!data) return null; - const obj: Record = {}; - for (const [key, val] of data.calibrations) { - obj[key] = val; - } - return { - calibrations: obj, - mapName: data.mapName, - mapType: data.mapType, - roundStarts: data.roundStarts, - }; -} diff --git a/src/data/killfeed-dto.ts b/src/data/killfeed-dto.ts deleted file mode 100644 index 2cf1f9cc6..000000000 --- a/src/data/killfeed-dto.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import prisma from "@/lib/prisma"; -import { groupKillsIntoFights, type Fight } from "@/lib/utils"; -import type { Kill } from "@prisma/client"; - -export type UltimateSpan = { - id: number; - ultimateId: number; - playerName: string; - playerTeam: string; - playerHero: string; - startTime: number; - endTime: number; - duration: number; - depth: number; - isInstant: boolean; - diedDuringUlt: boolean; - killsDuringUlt: Kill[]; - deathsDuringUlt: Kill[]; -}; - -export type FightUltimateData = { - fightIndex: number; - fightStart: number; - fightEnd: number; - spans: UltimateSpan[]; -}; - -export type KillfeedDisplayOptions = { - showTimeline: boolean; - showUltBrackets: boolean; - showUltLabels: boolean; - showUltStartEvents: boolean; - showUltEndEvents: boolean; - showUltKillHighlights: boolean; -}; - -export type KillfeedEvent = - | { type: "kill"; data: Kill } - | { type: "ult_start"; data: UltimateSpan } - | { type: "ult_end"; data: UltimateSpan } - | { type: "ult_instant"; data: UltimateSpan }; - -export const DEFAULT_KILLFEED_OPTIONS: KillfeedDisplayOptions = { - showTimeline: false, - showUltBrackets: false, - showUltLabels: false, - showUltStartEvents: false, - showUltEndEvents: false, - showUltKillHighlights: false, -}; - -const INSTANT_ULT_THRESHOLD = 1.0; -const NO_END_ULT_WINDOW = 5.0; - -function assignNestingDepths(spans: UltimateSpan[]): void { - const sorted = spans.sort((a, b) => a.startTime - b.startTime); - const activeEndTimes: number[] = []; - - for (const span of sorted) { - while ( - activeEndTimes.length > 0 && - activeEndTimes[activeEndTimes.length - 1] <= span.startTime - ) { - activeEndTimes.pop(); - } - - span.depth = activeEndTimes.length; - - let insertIdx = activeEndTimes.length; - for (let i = activeEndTimes.length - 1; i >= 0; i--) { - if (activeEndTimes[i] <= span.endTime) break; - insertIdx = i; - } - activeEndTimes.splice(insertIdx, 0, span.endTime); - } -} - -function assignSpanToFight(span: UltimateSpan, fights: Fight[]): number | null { - for (let i = 0; i < fights.length; i++) { - const fight = fights[i]; - if (span.startTime <= fight.end && span.endTime >= fight.start) { - return i; - } - } - return null; -} - -export async function getUltimateSpans( - mapId: number -): Promise { - const mapDataId = await resolveMapDataId(mapId); - const [ultimateStarts, ultimateEnds, kills, fights] = await Promise.all([ - prisma.ultimateStart.findMany({ - where: { MapDataId: mapDataId }, - orderBy: { match_time: "asc" }, - }), - prisma.ultimateEnd.findMany({ - where: { MapDataId: mapDataId }, - orderBy: { match_time: "asc" }, - }), - prisma.kill.findMany({ - where: { MapDataId: mapDataId }, - orderBy: { match_time: "asc" }, - }), - groupKillsIntoFights(mapId), - ]); - - if (fights.length === 0) return []; - - const spans: UltimateSpan[] = []; - const pairedEndIds = new Set(); - - for (const start of ultimateStarts) { - const end = ultimateEnds.find( - (e) => - !pairedEndIds.has(e.id) && - e.ultimate_id === start.ultimate_id && - e.player_name === start.player_name && - e.match_time >= start.match_time - ); - - if (end) { - pairedEndIds.add(end.id); - } - - const hasEnd = !!end; - const rawEndTime = end?.match_time ?? start.match_time; - const duration = rawEndTime - start.match_time; - const isInstant = !hasEnd || duration <= INSTANT_ULT_THRESHOLD; - - const effectiveEnd = hasEnd - ? rawEndTime - : start.match_time + NO_END_ULT_WINDOW; - - const killsDuringUlt = kills.filter( - (k) => - k.attacker_name === start.player_name && - k.match_time >= start.match_time && - k.match_time <= effectiveEnd - ); - - const deathsDuringUlt = kills.filter( - (k) => - k.victim_name === start.player_name && - k.match_time >= start.match_time && - k.match_time <= effectiveEnd - ); - - const diedDuringUlt = hasEnd - ? deathsDuringUlt.some((k) => k.match_time === rawEndTime) - : false; - - spans.push({ - id: start.id, - ultimateId: start.ultimate_id, - playerName: start.player_name, - playerTeam: start.player_team, - playerHero: start.player_hero, - startTime: start.match_time, - endTime: effectiveEnd, - duration, - depth: 0, - isInstant, - diedDuringUlt, - killsDuringUlt, - deathsDuringUlt, - }); - } - - const fightDataMap = new Map(); - - for (let i = 0; i < fights.length; i++) { - fightDataMap.set(i, { - fightIndex: i, - fightStart: fights[i].start, - fightEnd: fights[i].end, - spans: [], - }); - } - - for (const span of spans) { - const fightIdx = assignSpanToFight(span, fights); - if (fightIdx !== null) { - fightDataMap.get(fightIdx)!.spans.push(span); - } - } - - const result: FightUltimateData[] = []; - for (const [, fightData] of fightDataMap) { - if (fightData.spans.length > 0) { - assignNestingDepths(fightData.spans); - } - result.push(fightData); - } - - return result.sort((a, b) => a.fightIndex - b.fightIndex); -} - -export function hasAnyUltFeature(options: KillfeedDisplayOptions): boolean { - return ( - options.showUltBrackets || - options.showUltStartEvents || - options.showUltEndEvents || - options.showUltKillHighlights - ); -} - -export function mergeKillfeedEvents( - kills: Kill[], - spans: UltimateSpan[], - options: KillfeedDisplayOptions -): KillfeedEvent[] { - const events: KillfeedEvent[] = kills.map((k) => ({ - type: "kill" as const, - data: k, - })); - - const showStartRows = options.showUltStartEvents; - const showEndRows = options.showUltEndEvents; - - if (showStartRows || showEndRows) { - for (const span of spans) { - if (span.isInstant) { - if (showStartRows || showEndRows) { - events.push({ type: "ult_instant", data: span }); - } - } else { - if (showStartRows) { - events.push({ type: "ult_start", data: span }); - } - if (showEndRows) { - events.push({ type: "ult_end", data: span }); - } - } - } - } - - events.sort((a, b) => { - const timeA = getEventTime(a); - const timeB = getEventTime(b); - - if (timeA !== timeB) return timeA - timeB; - - const priority = { ult_start: 0, kill: 1, ult_end: 2, ult_instant: 0 }; - return priority[a.type] - priority[b.type]; - }); - - return events; -} - -export function getEventTime(event: KillfeedEvent): number { - if (event.type === "kill") return event.data.match_time; - if (event.type === "ult_start") return event.data.startTime; - if (event.type === "ult_instant") return event.data.startTime; - return event.data.endTime; -} - -export function isKillDuringUlt( - kill: Kill, - spans: UltimateSpan[] -): UltimateSpan | null { - for (const span of spans) { - if ( - kill.match_time >= span.startTime && - kill.match_time <= span.endTime && - kill.attacker_team === span.playerTeam - ) { - return span; - } - } - return null; -} diff --git a/src/data/map-group-dto.ts b/src/data/map-group-dto.ts deleted file mode 100644 index 83540a268..000000000 --- a/src/data/map-group-dto.ts +++ /dev/null @@ -1,110 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { MapGroup } from "@prisma/client"; -import { cache } from "react"; - -/** - * Get all map groups for a team - */ -export const getMapGroupsForTeam = cache( - async (teamId: number): Promise => { - return await prisma.mapGroup.findMany({ - where: { - teamId, - }, - orderBy: { - createdAt: "desc", - }, - }); - } -); - -/** - * Get a specific map group by ID - */ -export const getMapGroupById = cache( - async (groupId: number): Promise => { - return await prisma.mapGroup.findUnique({ - where: { - id: groupId, - }, - }); - } -); - -/** - * Create a new map group - */ -export async function createMapGroup(data: { - name: string; - description?: string; - teamId: number; - mapIds: number[]; - category?: string; - createdBy: string; -}): Promise { - return await prisma.mapGroup.create({ - data: { - name: data.name, - description: data.description, - teamId: data.teamId, - mapIds: data.mapIds, - category: data.category, - createdBy: data.createdBy, - }, - }); -} - -/** - * Update an existing map group - */ -export async function updateMapGroup( - groupId: number, - data: { - name?: string; - description?: string; - mapIds?: number[]; - category?: string; - } -): Promise { - return await prisma.mapGroup.update({ - where: { - id: groupId, - }, - data: { - name: data.name, - description: data.description, - mapIds: data.mapIds, - category: data.category, - }, - }); -} - -/** - * Delete a map group - */ -export async function deleteMapGroup(groupId: number): Promise { - await prisma.mapGroup.delete({ - where: { - id: groupId, - }, - }); -} - -/** - * Get map groups by category - */ -export const getMapGroupsByCategory = cache( - async (teamId: number, category: string): Promise => { - return await prisma.mapGroup.findMany({ - where: { - teamId, - category, - }, - orderBy: { - name: "asc", - }, - }); - } -); diff --git a/src/data/map-intelligence-dto.ts b/src/data/map-intelligence-dto.ts deleted file mode 100644 index deb54d747..000000000 --- a/src/data/map-intelligence-dto.ts +++ /dev/null @@ -1,413 +0,0 @@ -import "server-only"; - -import { assessConfidence, type ConfidenceMetadata } from "@/lib/confidence"; -import { - hasScrimData, - SCRIM_CONFIDENCE_THRESHOLDS, - type DataAvailabilityProfile, -} from "@/lib/data-availability"; -import { - getTeamStrengthRatings, - type TeamStrengthRating, -} from "./opponent-strength-dto"; -import { getOpponentMatchData } from "./scouting-dto"; -import { getOpponentScrimMapResults } from "./scrim-opponent-dto"; -import { getTeamWinrates } from "./team-stats-dto"; -import type { MapType } from "@prisma/client"; -import { cache } from "react"; - -const INITIAL_RATING = 1500; -const HALF_LIFE_DAYS = 90; -const DECAY_CONSTANT = Math.LN2 / HALF_LIFE_DAYS; -const RECENT_MAP_WINDOW = 10; - -function calculateWeight(matchDate: Date): number { - const daysAgo = (Date.now() - matchDate.getTime()) / (1000 * 60 * 60 * 24); - return Math.exp(-DECAY_CONSTANT * daysAgo); -} - -export type StrengthWeightedMapWR = { - mapName: string; - mapType: MapType; - rawWinRate: number; - strengthWeightedWinRate: number; - played: number; - won: number; - confidence: ConfidenceMetadata; - strengthRatingAvailable: boolean; - sources?: { owcsMaps: number; scrimMaps: number }; -}; - -export type MapPerformanceTrend = { - mapName: string; - overallWinRate: number; - recentWinRate: number; - overallPlayed: number; - recentPlayed: number; - delta: number; - trend: "improving" | "declining" | "stable"; -}; - -export type MapTypeDependency = { - mapType: MapType; - played: number; - won: number; - winRate: number; - strengthWeightedWinRate: number; - confidence: ConfidenceMetadata; -}; - -export type MapMatchupEntry = { - mapName: string; - mapType: MapType; - userWinRate: number | null; - userPlayed: number; - opponentWinRate: number; - opponentStrengthWeightedWR: number; - opponentPlayed: number; - netAdvantage: number | null; - userConfidence: ConfidenceMetadata; - opponentConfidence: ConfidenceMetadata; -}; - -export type MapIntelligence = { - strengthWeightedWRs: StrengthWeightedMapWR[]; - trends: MapPerformanceTrend[]; - mapTypeDependencies: MapTypeDependency[]; - matchupMatrix: MapMatchupEntry[]; -}; - -type MapResultRow = { - mapName: string; - mapType: MapType; - matchDate: Date; - team1: string; - team2: string; - teamSide: "team1" | "team2"; - winner: string; -}; - -async function getOpponentMapResults( - teamAbbr: string -): Promise { - const matches = await getOpponentMatchData(teamAbbr); - - const rows: MapResultRow[] = []; - for (const match of matches) { - const teamSide = - match.team1 === teamAbbr ? ("team1" as const) : ("team2" as const); - for (const map of match.maps) { - rows.push({ - mapName: map.mapName, - mapType: map.mapType, - matchDate: match.matchDate, - team1: match.team1, - team2: match.team2, - teamSide, - winner: map.winner, - }); - } - } - return rows; -} - -type MapResultRowWithSource = MapResultRow & { source: "owcs" | "scrim" }; - -function computeStrengthWeightedWRs( - rows: MapResultRowWithSource[], - teamAbbr: string, - ratingsMap: Map, - includesScrimData: boolean -): StrengthWeightedMapWR[] { - const strengthRatingAvailable = ratingsMap.size > 0; - - const byMap = new Map< - string, - { - mapType: MapType; - results: { won: boolean; opponentRating: number; weight: number }[]; - owcsMaps: number; - scrimMaps: number; - } - >(); - - for (const row of rows) { - const won = row.winner === row.teamSide; - const opponentAbbr = row.teamSide === "team1" ? row.team2 : row.team1; - const opponentRating = - ratingsMap.get(opponentAbbr)?.rating ?? INITIAL_RATING; - const weight = calculateWeight(row.matchDate); - - let entry = byMap.get(row.mapName); - if (!entry) { - entry = { mapType: row.mapType, results: [], owcsMaps: 0, scrimMaps: 0 }; - byMap.set(row.mapName, entry); - } - entry.results.push({ won, opponentRating, weight }); - if (row.source === "scrim") { - entry.scrimMaps++; - } else { - entry.owcsMaps++; - } - } - - const confidenceThresholds = includesScrimData - ? SCRIM_CONFIDENCE_THRESHOLDS - : undefined; - - return Array.from(byMap.entries()) - .map(([mapName, { mapType, results, owcsMaps, scrimMaps }]) => { - const played = results.length; - const won = results.filter((r) => r.won).length; - const rawWinRate = played > 0 ? (won / played) * 100 : 0; - - let weightedWinSum = 0; - let weightedTotalSum = 0; - for (const r of results) { - const ratingWeight = strengthRatingAvailable - ? r.opponentRating / INITIAL_RATING - : 1; - const combinedWeight = ratingWeight * r.weight; - weightedWinSum += r.won ? combinedWeight : 0; - weightedTotalSum += combinedWeight; - } - const strengthWeightedWinRate = - weightedTotalSum > 0 - ? (weightedWinSum / weightedTotalSum) * 100 - : rawWinRate; - - return { - mapName, - mapType, - rawWinRate, - strengthWeightedWinRate: strengthRatingAvailable - ? strengthWeightedWinRate - : rawWinRate, - played, - won, - confidence: assessConfidence(played, confidenceThresholds), - strengthRatingAvailable, - sources: includesScrimData ? { owcsMaps, scrimMaps } : undefined, - }; - }) - .sort((a, b) => b.strengthWeightedWinRate - a.strengthWeightedWinRate); -} - -function computeTrends(rows: MapResultRowWithSource[]): MapPerformanceTrend[] { - const byMap = new Map(); - - for (const row of rows) { - const won = row.winner === row.teamSide; - let results = byMap.get(row.mapName); - if (!results) { - results = []; - byMap.set(row.mapName, results); - } - results.push({ won, date: row.matchDate }); - } - - return Array.from(byMap.entries()) - .map(([mapName, results]) => { - results.sort((a, b) => b.date.getTime() - a.date.getTime()); - - const overallPlayed = results.length; - const overallWon = results.filter((r) => r.won).length; - const overallWinRate = - overallPlayed > 0 ? (overallWon / overallPlayed) * 100 : 0; - - const recent = results.slice(0, RECENT_MAP_WINDOW); - const recentPlayed = recent.length; - const recentWon = recent.filter((r) => r.won).length; - const recentWinRate = - recentPlayed > 0 ? (recentWon / recentPlayed) * 100 : 0; - - const delta = recentWinRate - overallWinRate; - const TREND_THRESHOLD = 10; - const trend: MapPerformanceTrend["trend"] = - delta > TREND_THRESHOLD - ? "improving" - : delta < -TREND_THRESHOLD - ? "declining" - : "stable"; - - return { - mapName, - overallWinRate, - recentWinRate, - overallPlayed, - recentPlayed, - delta, - trend, - }; - }) - .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); -} - -function computeMapTypeDependencies( - rows: MapResultRowWithSource[], - ratingsMap: Map, - includesScrimData: boolean -): MapTypeDependency[] { - const byType = new Map< - MapType, - { results: { won: boolean; opponentRating: number; weight: number }[] } - >(); - - for (const row of rows) { - const won = row.winner === row.teamSide; - const opponentAbbr = row.teamSide === "team1" ? row.team2 : row.team1; - const opponentRating = - ratingsMap.get(opponentAbbr)?.rating ?? INITIAL_RATING; - const weight = calculateWeight(row.matchDate); - - let entry = byType.get(row.mapType); - if (!entry) { - entry = { results: [] }; - byType.set(row.mapType, entry); - } - entry.results.push({ won, opponentRating, weight }); - } - - const strengthRatingAvailable = ratingsMap.size > 0; - const confidenceThresholds = includesScrimData - ? SCRIM_CONFIDENCE_THRESHOLDS - : undefined; - - return Array.from(byType.entries()) - .map(([mapType, { results }]) => { - const played = results.length; - const won = results.filter((r) => r.won).length; - const winRate = played > 0 ? (won / played) * 100 : 0; - - let weightedWinSum = 0; - let weightedTotalSum = 0; - for (const r of results) { - const ratingWeight = strengthRatingAvailable - ? r.opponentRating / INITIAL_RATING - : 1; - const combinedWeight = ratingWeight * r.weight; - weightedWinSum += r.won ? combinedWeight : 0; - weightedTotalSum += combinedWeight; - } - const strengthWeightedWinRate = - weightedTotalSum > 0 - ? (weightedWinSum / weightedTotalSum) * 100 - : winRate; - - return { - mapType, - played, - won, - winRate, - strengthWeightedWinRate: strengthRatingAvailable - ? strengthWeightedWinRate - : winRate, - confidence: assessConfidence(played, confidenceThresholds), - }; - }) - .sort((a, b) => b.winRate - a.winRate); -} - -function buildMatchupMatrix( - opponentWRs: StrengthWeightedMapWR[], - userMapWinrates: Record< - string, - { totalWinrate: number; totalWins: number; totalLosses: number } - > -): MapMatchupEntry[] { - return opponentWRs - .map((opp) => { - const userMap = userMapWinrates[opp.mapName]; - const userPlayed = userMap ? userMap.totalWins + userMap.totalLosses : 0; - const userWinRate = userMap ? userMap.totalWinrate : null; - - const netAdvantage = - userWinRate !== null ? userWinRate - opp.strengthWeightedWinRate : null; - - return { - mapName: opp.mapName, - mapType: opp.mapType, - userWinRate, - userPlayed, - opponentWinRate: opp.rawWinRate, - opponentStrengthWeightedWR: opp.strengthWeightedWinRate, - opponentPlayed: opp.played, - netAdvantage, - userConfidence: assessConfidence(userPlayed), - opponentConfidence: opp.confidence, - }; - }) - .sort((a, b) => { - if (a.netAdvantage === null && b.netAdvantage === null) return 0; - if (a.netAdvantage === null) return 1; - if (b.netAdvantage === null) return -1; - return b.netAdvantage - a.netAdvantage; - }); -} - -async function getMapIntelligenceFn( - opponentAbbr: string, - userTeamId: number | null, - profile?: DataAvailabilityProfile -): Promise { - const [owcsRows, allRatings] = await Promise.all([ - getOpponentMapResults(opponentAbbr), - getTeamStrengthRatings(), - ]); - - const owcsRowsWithSource: MapResultRowWithSource[] = owcsRows.map((r) => ({ - ...r, - source: "owcs" as const, - })); - - let rows: MapResultRowWithSource[] = owcsRowsWithSource; - const includesScrimData = !!profile && !!userTeamId && hasScrimData(profile); - - if (includesScrimData && userTeamId !== null) { - const scrimResults = await getOpponentScrimMapResults( - userTeamId, - opponentAbbr - ); - const scrimRows: MapResultRowWithSource[] = scrimResults.map((r) => ({ - mapName: r.mapName, - mapType: r.mapType, - matchDate: r.scrimDate, - team1: "user", - team2: opponentAbbr, - teamSide: "team1" as const, - winner: r.opponentWon ? "team2" : "team1", - source: "scrim" as const, - })); - rows = [...owcsRowsWithSource, ...scrimRows]; - } - - const ratingsMap = new Map(allRatings.map((r) => [r.teamAbbr, r])); - - const strengthWeightedWRs = computeStrengthWeightedWRs( - rows, - opponentAbbr, - ratingsMap, - includesScrimData - ); - const trends = computeTrends(rows); - const mapTypeDependencies = computeMapTypeDependencies( - rows, - ratingsMap, - includesScrimData - ); - - let matchupMatrix: MapMatchupEntry[] = []; - if (userTeamId !== null) { - const userWinrates = await getTeamWinrates(userTeamId); - matchupMatrix = buildMatchupMatrix(strengthWeightedWRs, userWinrates.byMap); - } - - return { - strengthWeightedWRs, - trends, - mapTypeDependencies, - matchupMatrix, - }; -} - -export const getMapIntelligence = cache(getMapIntelligenceFn); diff --git a/src/data/opponent-strength-dto.ts b/src/data/opponent-strength-dto.ts deleted file mode 100644 index f61ebfea9..000000000 --- a/src/data/opponent-strength-dto.ts +++ /dev/null @@ -1,134 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { cache } from "react"; - -export type TeamStrengthRating = { - teamAbbr: string; - fullName: string; - rating: number; - matchesRated: number; - ratingHistory: { date: Date; rating: number }[]; -}; - -const INITIAL_RATING = 1500; - -/** - * K-factor decays as sample size grows. Early matches have more volatility; - * established teams converge toward stable ratings. - */ -function kFactor(matchesPlayed: number): number { - if (matchesPlayed < 5) return 48; - if (matchesPlayed < 15) return 32; - if (matchesPlayed < 30) return 24; - return 16; -} - -function expectedScore(ratingA: number, ratingB: number): number { - return 1 / (1 + Math.pow(10, (ratingB - ratingA) / 400)); -} - -type MatchRecord = { - matchDate: Date; - team1: string; - team1FullName: string; - team2: string; - team2FullName: string; - winner: string | null; -}; - -async function getTeamStrengthRatingsFn(): Promise { - const matches = await prisma.scoutingMatch.findMany({ - select: { - matchDate: true, - team1: true, - team1FullName: true, - team2: true, - team2FullName: true, - winner: true, - }, - orderBy: { matchDate: "asc" }, - }); - - const ratings = new Map(); - const fullNames = new Map(); - const matchCounts = new Map(); - const histories = new Map(); - - function ensureTeam(abbr: string, name: string) { - if (!ratings.has(abbr)) { - ratings.set(abbr, INITIAL_RATING); - fullNames.set(abbr, name); - matchCounts.set(abbr, 0); - histories.set(abbr, []); - } - } - - function recordHistory(abbr: string, date: Date) { - const history = histories.get(abbr)!; - history.push({ date, rating: ratings.get(abbr)! }); - } - - for (const match of matches as MatchRecord[]) { - ensureTeam(match.team1, match.team1FullName); - ensureTeam(match.team2, match.team2FullName); - - if (!match.winner) continue; - - const r1 = ratings.get(match.team1)!; - const r2 = ratings.get(match.team2)!; - const n1 = matchCounts.get(match.team1)!; - const n2 = matchCounts.get(match.team2)!; - - const e1 = expectedScore(r1, r2); - const e2 = 1 - e1; - const s1 = match.winner === match.team1 ? 1 : 0; - const s2 = 1 - s1; - - const k1 = kFactor(n1); - const k2 = kFactor(n2); - - ratings.set(match.team1, r1 + k1 * (s1 - e1)); - ratings.set(match.team2, r2 + k2 * (s2 - e2)); - - matchCounts.set(match.team1, n1 + 1); - matchCounts.set(match.team2, n2 + 1); - - recordHistory(match.team1, match.matchDate); - recordHistory(match.team2, match.matchDate); - } - - return Array.from(ratings.entries()) - .map(([teamAbbr, rating]) => ({ - teamAbbr, - fullName: fullNames.get(teamAbbr)!, - rating: Math.round(rating), - matchesRated: matchCounts.get(teamAbbr)!, - ratingHistory: histories.get(teamAbbr)!, - })) - .sort((a, b) => b.rating - a.rating); -} - -export const getTeamStrengthRatings = cache(getTeamStrengthRatingsFn); - -export async function getTeamStrengthRating( - teamAbbr: string -): Promise { - const ratings = await getTeamStrengthRatings(); - return ratings.find((r) => r.teamAbbr === teamAbbr) ?? null; -} - -/** - * Returns a percentile rank (0–100) for a given team's rating - * relative to all rated teams. - */ -export async function getTeamStrengthPercentile( - teamAbbr: string -): Promise { - const ratings = await getTeamStrengthRatings(); - const index = ratings.findIndex((r) => r.teamAbbr === teamAbbr); - if (index === -1) return null; - return Math.round( - ((ratings.length - 1 - index) / (ratings.length - 1)) * 100 - ); -} diff --git a/src/data/player-dto.tsx b/src/data/player-dto.tsx deleted file mode 100644 index 8b8766631..000000000 --- a/src/data/player-dto.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import "server-only"; - -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import prisma from "@/lib/prisma"; -import { cache } from "react"; - -async function getMostPlayedHeroesFn(id: number) { - const mapDataId = await resolveMapDataId(id); - return await prisma.playerStat.findMany({ - where: { MapDataId: mapDataId }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - }, - orderBy: { hero_time_played: "desc" }, - distinct: ["player_name"], - }); -} - -export const getMostPlayedHeroes = cache(getMostPlayedHeroesFn); diff --git a/src/data/player-intelligence-dto.ts b/src/data/player-intelligence-dto.ts deleted file mode 100644 index 9a41d88a9..000000000 --- a/src/data/player-intelligence-dto.ts +++ /dev/null @@ -1,551 +0,0 @@ -import "server-only"; - -import { assessConfidence, type ConfidenceMetadata } from "@/lib/confidence"; -import { - hasScrimData, - type DataAvailabilityProfile, -} from "@/lib/data-availability"; -import prisma from "@/lib/prisma"; -import { type HeroName, heroRoleMapping } from "@/types/heroes"; -import { cache } from "react"; -import { getBaseTeamData } from "./team-shared-data"; -import { getOpponentMatchData } from "./scouting-dto"; -import { getOpponentScrimHeroBans } from "./scrim-opponent-dto"; - -type HeroRole = "Tank" | "Damage" | "Support"; - -export type PlayerHeroZScore = { - hero: string; - role: HeroRole; - compositeZScore: number; - mapsPlayed: number; - totalTimePlayed: number; - isPrimary: boolean; -}; - -export type PlayerHeroDepth = { - playerName: string; - role: HeroRole; - heroes: PlayerHeroZScore[]; - primarySecondaryDelta: number | null; - heroPoolSize: number; - confidence: ConfidenceMetadata; -}; - -export type HeroSubstitutionRate = { - playerName: string; - primaryHero: string; - totalMaps: number; - mapsOnPrimary: number; - mapsForced: number; - substitutionRate: number; - performanceDelta: number | null; -}; - -export type PlayerVulnerability = { - playerName: string; - role: HeroRole; - primaryHero: string; - heroDepthDelta: number; - opponentBanRate: number; - opponentBanCount: number; - vulnerabilityIndex: number; - riskLevel: "critical" | "high" | "moderate" | "low"; -}; - -export type BestPlayerHighlight = { - playerName: string; - role: HeroRole; - primaryHero: string; - compositeZScore: number; - mapsPlayed: number; - isTargetedByBans: boolean; - banTargetRate: number; -}; - -export type PlayerIntelligence = { - playerDepths: PlayerHeroDepth[]; - substitutionRates: HeroSubstitutionRate[]; - vulnerabilities: PlayerVulnerability[]; - bestPlayer: BestPlayerHighlight | null; -}; - -const MIN_HERO_TIME_SECONDS = 120; -const MIN_MAPS_FOR_HERO = 3; - -type PlayerHeroAgg = { - hero: string; - role: HeroRole; - maps: number; - totalTime: number; - elimsPer10: number; - deathsPer10: number; - damagePer10: number; - healingPer10: number; -}; - -function aggregatePlayerHeroes( - playerName: string, - allPlayerStats: { - player_name: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - eliminations: number; - deaths: number; - hero_damage_dealt: number; - healing_dealt: number; - }[], - teamRosterSet: Set -): PlayerHeroAgg[] { - if (!teamRosterSet.has(playerName)) return []; - - // Group by hero, deduplicating by MapDataId (take latest stat per map per hero) - const heroData = new Map< - string, - { - mapIds: Set; - totalTime: number; - totalElims: number; - totalDeaths: number; - totalDamage: number; - totalHealing: number; - } - >(); - - for (const stat of allPlayerStats) { - if (stat.player_name !== playerName || !stat.MapDataId) continue; - if (stat.hero_time_played < MIN_HERO_TIME_SECONDS) continue; - - let data = heroData.get(stat.player_hero); - if (!data) { - data = { - mapIds: new Set(), - totalTime: 0, - totalElims: 0, - totalDeaths: 0, - totalDamage: 0, - totalHealing: 0, - }; - heroData.set(stat.player_hero, data); - } - - if (!data.mapIds.has(stat.MapDataId)) { - data.mapIds.add(stat.MapDataId); - data.totalTime += stat.hero_time_played; - data.totalElims += stat.eliminations; - data.totalDeaths += stat.deaths; - data.totalDamage += stat.hero_damage_dealt; - data.totalHealing += stat.healing_dealt; - } - } - - return Array.from(heroData.entries()) - .filter(([, data]) => data.mapIds.size >= MIN_MAPS_FOR_HERO) - .map(([hero, data]) => { - const timePer10 = data.totalTime > 0 ? 600 / data.totalTime : 0; - const role = heroRoleMapping[hero as HeroName] ?? ("Damage" as HeroRole); - return { - hero, - role, - maps: data.mapIds.size, - totalTime: data.totalTime, - elimsPer10: data.totalElims * timePer10, - deathsPer10: data.totalDeaths * timePer10, - damagePer10: data.totalDamage * timePer10, - healingPer10: data.totalHealing * timePer10, - }; - }) - .sort((a, b) => b.totalTime - a.totalTime); -} - -/** - * Computes a composite z-score for each player-hero combination by - * normalizing per-10 stats within the same **role** across all players. - * - * Grouping by role (not by hero) is critical: on a typical roster only - * one player plays each hero, so per-hero stddev would be 0 and every - * z-score would collapse to 0. Pooling all Tank hero-performances into - * one distribution, all Damage into another, and all Support into a - * third gives us enough data points for meaningful variance. - */ -function computeIntraTeamZScores( - allPlayerHeroes: Map -): Map> { - type StatBucket = { - elims: number[]; - deaths: number[]; - damage: number[]; - healing: number[]; - }; - - const roleStats = new Map(); - - for (const [, heroes] of allPlayerHeroes) { - for (const h of heroes) { - let bucket = roleStats.get(h.role); - if (!bucket) { - bucket = { elims: [], deaths: [], damage: [], healing: [] }; - roleStats.set(h.role, bucket); - } - bucket.elims.push(h.elimsPer10); - bucket.deaths.push(h.deathsPer10); - bucket.damage.push(h.damagePer10); - bucket.healing.push(h.healingPer10); - } - } - - function mean(arr: number[]) { - return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; - } - - function stddev(arr: number[]) { - if (arr.length < 2) return 0; - const m = mean(arr); - return Math.sqrt( - arr.reduce((sum, v) => sum + (v - m) ** 2, 0) / (arr.length - 1) - ); - } - - const zScores = new Map>(); - - for (const [playerName, heroes] of allPlayerHeroes) { - const playerZScores = new Map(); - for (const h of heroes) { - const stats = roleStats.get(h.role); - if (!stats) continue; - - const weights = - h.role === "Tank" - ? { elims: 0.2, deaths: -0.3, damage: 0.2, healing: 0 } - : h.role === "Support" - ? { elims: 0.1, deaths: -0.25, damage: 0.14, healing: 0.35 } - : { elims: 0.3, deaths: -0.2, damage: 0.3, healing: 0 }; - - let composite = 0; - const elimStd = stddev(stats.elims); - const deathStd = stddev(stats.deaths); - const damageStd = stddev(stats.damage); - const healingStd = stddev(stats.healing); - - if (elimStd > 0) - composite += - ((h.elimsPer10 - mean(stats.elims)) / elimStd) * weights.elims; - if (deathStd > 0) - composite += - ((h.deathsPer10 - mean(stats.deaths)) / deathStd) * weights.deaths; - if (damageStd > 0) - composite += - ((h.damagePer10 - mean(stats.damage)) / damageStd) * weights.damage; - if (healingStd > 0) - composite += - ((h.healingPer10 - mean(stats.healing)) / healingStd) * - weights.healing; - - playerZScores.set(h.hero, composite); - } - zScores.set(playerName, playerZScores); - } - - return zScores; -} - -function buildPlayerDepths( - allPlayerHeroes: Map, - zScores: Map> -): PlayerHeroDepth[] { - const depths: PlayerHeroDepth[] = []; - - for (const [playerName, heroes] of allPlayerHeroes) { - if (heroes.length === 0) continue; - - const playerZScores = zScores.get(playerName) ?? new Map(); - const primaryRole = heroes[0].role; - - const heroZScores: PlayerHeroZScore[] = heroes.map((h, i) => ({ - hero: h.hero, - role: h.role, - compositeZScore: playerZScores.get(h.hero) ?? 0, - mapsPlayed: h.maps, - totalTimePlayed: h.totalTime, - isPrimary: i === 0, - })); - - heroZScores.sort((a, b) => b.compositeZScore - a.compositeZScore); - if (heroZScores.length > 0) heroZScores[0].isPrimary = true; - - let primarySecondaryDelta: number | null = null; - if (heroZScores.length >= 2) { - primarySecondaryDelta = - heroZScores[0].compositeZScore - heroZScores[1].compositeZScore; - } - - const totalMaps = heroes.reduce((sum, h) => sum + h.maps, 0); - - depths.push({ - playerName, - role: primaryRole, - heroes: heroZScores, - primarySecondaryDelta, - heroPoolSize: heroZScores.length, - confidence: assessConfidence(totalMaps), - }); - } - - return depths.sort((a, b) => { - const aTop = a.heroes[0]?.compositeZScore ?? 0; - const bTop = b.heroes[0]?.compositeZScore ?? 0; - return bTop - aTop; - }); -} - -function computeSubstitutionRates( - allPlayerHeroes: Map, - allPlayerStats: { - player_name: string; - player_hero: string; - MapDataId: number | null; - hero_time_played: number; - }[], - teamRosterSet: Set, - heroBans: { MapDataId: number; hero: string }[] -): HeroSubstitutionRate[] { - const bansByMap = new Map>(); - for (const ban of heroBans) { - let bans = bansByMap.get(ban.MapDataId); - if (!bans) { - bans = new Set(); - bansByMap.set(ban.MapDataId, bans); - } - bans.add(ban.hero); - } - - const playerMapIds = new Map>(); - const playerHeroMaps = new Map>(); - - for (const stat of allPlayerStats) { - if (!stat.MapDataId || stat.hero_time_played < MIN_HERO_TIME_SECONDS) { - continue; - } - if (!teamRosterSet.has(stat.player_name)) continue; - - let mapIds = playerMapIds.get(stat.player_name); - if (!mapIds) { - mapIds = new Set(); - playerMapIds.set(stat.player_name, mapIds); - } - mapIds.add(stat.MapDataId); - - const heroKey = `${stat.player_name}\0${stat.player_hero}`; - let heroMapIds = playerHeroMaps.get(heroKey); - if (!heroMapIds) { - heroMapIds = new Set(); - playerHeroMaps.set(heroKey, heroMapIds); - } - heroMapIds.add(stat.MapDataId); - } - - const results: HeroSubstitutionRate[] = []; - - for (const [playerName, heroes] of allPlayerHeroes) { - if (!teamRosterSet.has(playerName) || heroes.length === 0) continue; - - const primaryHero = heroes[0].hero; - const mapIds = playerMapIds.get(playerName) ?? new Set(); - const primaryHeroMapIds = - playerHeroMaps.get(`${playerName}\0${primaryHero}`) ?? new Set(); - - const totalMaps = mapIds.size; - let mapsOnPrimary = 0; - let mapsForced = 0; - - for (const mapId of mapIds) { - if (primaryHeroMapIds.has(mapId)) { - mapsOnPrimary++; - } else { - const bans = bansByMap.get(mapId); - if (bans?.has(primaryHero)) { - mapsForced++; - } - } - } - - const substitutionRate = totalMaps > 0 ? (mapsForced / totalMaps) * 100 : 0; - - results.push({ - playerName, - primaryHero, - totalMaps, - mapsOnPrimary, - mapsForced, - substitutionRate, - performanceDelta: null, - }); - } - - return results.sort((a, b) => b.substitutionRate - a.substitutionRate); -} - -function computeVulnerabilities( - playerDepths: PlayerHeroDepth[], - opponentBanCounts: Map, - totalOpponentMaps: number -): PlayerVulnerability[] { - return playerDepths - .filter((p) => p.heroes.length >= 1) - .map((p) => { - const primaryHero = p.heroes[0].hero; - const heroDepthDelta = p.primarySecondaryDelta ?? 0; - const opponentBanCount = opponentBanCounts.get(primaryHero) ?? 0; - const opponentBanRate = - totalOpponentMaps > 0 - ? (opponentBanCount / totalOpponentMaps) * 100 - : 0; - - // Vulnerability index = hero depth delta * opponent ban rate / 100 - // High delta + high ban rate = high vulnerability - const vulnerabilityIndex = heroDepthDelta * (opponentBanRate / 100); - - const CRITICAL_THRESHOLD = 0.5; - const HIGH_THRESHOLD = 0.25; - const MODERATE_THRESHOLD = 0.1; - - const riskLevel: PlayerVulnerability["riskLevel"] = - vulnerabilityIndex >= CRITICAL_THRESHOLD - ? "critical" - : vulnerabilityIndex >= HIGH_THRESHOLD - ? "high" - : vulnerabilityIndex >= MODERATE_THRESHOLD - ? "moderate" - : "low"; - - return { - playerName: p.playerName, - role: p.role, - primaryHero, - heroDepthDelta, - opponentBanRate, - opponentBanCount, - vulnerabilityIndex, - riskLevel, - }; - }) - .sort((a, b) => b.vulnerabilityIndex - a.vulnerabilityIndex); -} - -function findBestPlayer( - playerDepths: PlayerHeroDepth[], - opponentBanCounts: Map, - totalOpponentMaps: number -): BestPlayerHighlight | null { - if (playerDepths.length === 0) return null; - - const best = playerDepths[0]; - if (best.heroes.length === 0) return null; - - const primaryHero = best.heroes[0].hero; - const banCount = opponentBanCounts.get(primaryHero) ?? 0; - const banRate = - totalOpponentMaps > 0 ? (banCount / totalOpponentMaps) * 100 : 0; - - return { - playerName: best.playerName, - role: best.role, - primaryHero, - compositeZScore: best.heroes[0].compositeZScore, - mapsPlayed: best.heroes.reduce((sum, h) => sum + h.mapsPlayed, 0), - isTargetedByBans: banRate >= 15, - banTargetRate: banRate, - }; -} - -async function getPlayerIntelligenceFn( - userTeamId: number, - opponentAbbr: string | null, - profile?: DataAvailabilityProfile -): Promise { - const baseData = await getBaseTeamData(userTeamId); - const { allPlayerStats, teamRoster, teamRosterSet } = baseData; - - // Aggregate hero stats per player - const allPlayerHeroes = new Map(); - for (const player of teamRoster) { - const heroes = aggregatePlayerHeroes(player, allPlayerStats, teamRosterSet); - if (heroes.length > 0) { - allPlayerHeroes.set(player, heroes); - } - } - - const zScores = computeIntraTeamZScores(allPlayerHeroes); - const playerDepths = buildPlayerDepths(allPlayerHeroes, zScores); - - // Get internal hero bans for substitution rate computation - const heroBans = await prisma.heroBan.findMany({ - where: { MapDataId: { in: baseData.mapDataIds } }, - select: { MapDataId: true, hero: true }, - }); - - const substitutionRates = computeSubstitutionRates( - allPlayerHeroes, - allPlayerStats, - teamRosterSet, - heroBans - ); - - const opponentBanCounts = new Map(); - let totalOpponentMaps = 0; - - if (opponentAbbr) { - const opponentMatches = await getOpponentMatchData(opponentAbbr); - - for (const match of opponentMatches) { - const opponentSide = match.team1 === opponentAbbr ? "team2" : "team1"; - for (const map of match.maps) { - totalOpponentMaps++; - for (const ban of map.heroBans) { - if (ban.team === opponentSide) { - opponentBanCounts.set( - ban.hero, - (opponentBanCounts.get(ban.hero) ?? 0) + 1 - ); - } - } - } - } - - const includesScrimData = !!profile && hasScrimData(profile); - if (includesScrimData) { - const scrimBans = await getOpponentScrimHeroBans( - userTeamId, - opponentAbbr - ); - for (const scrimMap of scrimBans) { - totalOpponentMaps++; - for (const hero of scrimMap.opponentBans) { - opponentBanCounts.set(hero, (opponentBanCounts.get(hero) ?? 0) + 1); - } - } - } - } - - const vulnerabilities = computeVulnerabilities( - playerDepths, - opponentBanCounts, - totalOpponentMaps - ); - - const bestPlayer = findBestPlayer( - playerDepths, - opponentBanCounts, - totalOpponentMaps - ); - - return { - playerDepths, - substitutionRates, - vulnerabilities, - bestPlayer, - }; -} - -export const getPlayerIntelligence = cache(getPlayerIntelligenceFn); diff --git a/src/data/player-scouting-analytics-dto.ts b/src/data/player-scouting-analytics-dto.ts deleted file mode 100644 index 8a3cd332d..000000000 --- a/src/data/player-scouting-analytics-dto.ts +++ /dev/null @@ -1,1079 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { ValidStatColumn } from "@/lib/stat-percentiles"; -import { removeDuplicateRows } from "@/lib/utils"; -import type { HeroName } from "@/types/heroes"; -import { heroRoleMapping } from "@/types/heroes"; -import { CalculatedStatType, type PlayerStat, Prisma } from "@prisma/client"; -import { cache } from "react"; - -type HeroRole = "Tank" | "Damage" | "Support"; - -export type HeroStatZScore = { - stat: ValidStatColumn; - per10: number; - zScore: number | null; - heroAvgPer10: number; - heroStdPer10: number; - sampleSize: number; -}; - -export type ScoutingHeroPerformance = { - hero: string; - role: HeroRole; - mapsPlayed: number; - totalTimePlayed: number; - stats: HeroStatZScore[]; - compositeZScore: number; -}; - -export type AdvancedMetrics = { - mvpScore: number; - fletaDeadliftPercentage: number; - firstPickPercentage: number; - firstPickCount: number; - firstDeathPercentage: number; - firstDeathCount: number; - fightReversalPercentage: number; - killsPerUltimate: number; - averageUltChargeTime: number; - averageDroughtTime: number; - duelWinratePercentage: number; - consistencyScore: number; -}; - -export type KillPatterns = { - topHeroesEliminated: { hero: string; count: number }[]; - topHeroesDiedTo: { hero: string; count: number }[]; - killMethods: { method: string; count: number }[]; -}; - -export type RoleDistributionEntry = { - role: string; - timePlayed: number; - percentage: number; -}; - -export type AccuracyStats = { - weaponAccuracy: number; - criticalHitAccuracy: number; - scopedAccuracy: number; -}; - -export type MapWinrateEntry = { - mapName: string; - mapType: string; - wins: number; - losses: number; - winRate: number; - played: number; -}; - -export type MapTypeWinrateEntry = { - mapType: string; - wins: number; - losses: number; - winRate: number; - played: number; -}; - -export type CompetitiveMapWinrates = { - byMapType: MapTypeWinrateEntry[]; - byMap: MapWinrateEntry[]; -}; - -export type ScrimMapWinrates = { - byMap: { mapName: string; wins: number; losses: number; winRate: number }[]; -}; - -export type ScrimData = { - available: true; - mapsPlayed: number; - totalTimePlayed: number; - heroes: ScoutingHeroPerformance[]; - advancedMetrics: AdvancedMetrics; - killPatterns: KillPatterns; - roleDistribution: RoleDistributionEntry[]; - accuracy: AccuracyStats; - kdRatio: number; - eliminationsPer10: number; - deathsPer10: number; - heroDamagePer10: number; - healingDealtPer10: number; -}; - -export type InsightItem = { - category: "hero" | "map" | "stat" | "combat"; - label: string; - detail: string; - value: number; -}; - -export type PlayerScoutingAnalytics = { - scrimData: ScrimData | null; - competitiveMapWinrates: CompetitiveMapWinrates; - scrimMapWinrates: ScrimMapWinrates | null; - strengths: InsightItem[]; - weaknesses: InsightItem[]; -}; - -const MIN_HERO_TIME_SECONDS = 120; -const MIN_HERO_MAPS = 2; - -const BASELINE_STAT_COLUMNS: ValidStatColumn[] = [ - "eliminations", - "deaths", - "hero_damage_dealt", - "healing_dealt", - "damage_blocked", -]; - -type StatDistributionBaseline = { - hero: string; - stat_name: string; - hero_avg_per10: number; - hero_std_per10: number; - total_players: number; -}; - -type BatchedBaselineRow = { - hero: string; - avg_eliminations_per10: number | null; - std_eliminations_per10: number | null; - avg_deaths_per10: number | null; - std_deaths_per10: number | null; - avg_hero_damage_dealt_per10: number | null; - std_hero_damage_dealt_per10: number | null; - avg_healing_dealt_per10: number | null; - std_healing_dealt_per10: number | null; - avg_damage_blocked_per10: number | null; - std_damage_blocked_per10: number | null; - total_players: number; -}; - -function createBaselineKey(hero: string, stat: ValidStatColumn): string { - return `${hero}::${stat}`; -} - -function calculatePer10(value: number, timePlayed: number): number { - if (timePlayed === 0) return 0; - return (value / timePlayed) * 600; -} - -function calculatePercentage(value: number, total: number): number { - if (total === 0) return 0; - return (value / total) * 100; -} - -function calculateZScoreFromBaseline( - baseline: StatDistributionBaseline, - stat: ValidStatColumn, - per10Value: number -): number | null { - if (!baseline.hero_std_per10 || baseline.hero_std_per10 === 0) return null; - const isInverted = stat === "deaths" || stat === "damage_taken"; - const zScore = isInverted - ? (baseline.hero_avg_per10 - per10Value) / baseline.hero_std_per10 - : (per10Value - baseline.hero_avg_per10) / baseline.hero_std_per10; - if (!Number.isFinite(zScore)) return null; - return zScore; -} - -async function fetchBaselinesByHeroes( - heroes: HeroName[], - minMaps = 5, - minTimeSeconds = 300, - sampleLimit = 150 -): Promise> { - if (heroes.length === 0) return new Map(); - - const rows = await prisma.$queryRaw` - WITH - final_rows AS ( - SELECT DISTINCT ON ("MapDataId", player_name, player_hero) - player_name, - player_hero, - eliminations, - deaths, - hero_damage_dealt, - healing_dealt, - damage_blocked, - hero_time_played - FROM - "PlayerStat" - WHERE - player_hero IN (${Prisma.join(heroes)}) - AND hero_time_played >= 60 - ORDER BY - "MapDataId", - player_name, - player_hero, - round_number DESC, - id DESC - ), - per_player_totals AS ( - SELECT - player_hero, - player_name, - COUNT(*) AS maps, - (SUM(eliminations)::numeric / SUM(hero_time_played)) * 600.0 AS elims_per10, - (SUM(deaths)::numeric / SUM(hero_time_played)) * 600.0 AS deaths_per10, - (SUM(hero_damage_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS dmg_per10, - (SUM(healing_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS heal_per10, - (SUM(damage_blocked)::numeric / SUM(hero_time_played)) * 600.0 AS block_per10 - FROM - final_rows - GROUP BY - player_name, player_hero - HAVING - COUNT(*) >= ${minMaps} - AND SUM(hero_time_played) >= ${minTimeSeconds} - ), - ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY player_hero - ORDER BY maps DESC, player_name - ) AS rn - FROM - per_player_totals - ), - limited AS ( - SELECT * FROM ranked WHERE rn <= ${sampleLimit} - ) - SELECT - player_hero AS hero, - ROUND(AVG(elims_per10)::numeric, 2) AS avg_eliminations_per10, - ROUND(STDDEV_SAMP(elims_per10)::numeric, 2) AS std_eliminations_per10, - ROUND(AVG(deaths_per10)::numeric, 2) AS avg_deaths_per10, - ROUND(STDDEV_SAMP(deaths_per10)::numeric, 2) AS std_deaths_per10, - ROUND(AVG(dmg_per10)::numeric, 2) AS avg_hero_damage_dealt_per10, - ROUND(STDDEV_SAMP(dmg_per10)::numeric, 2) AS std_hero_damage_dealt_per10, - ROUND(AVG(heal_per10)::numeric, 2) AS avg_healing_dealt_per10, - ROUND(STDDEV_SAMP(heal_per10)::numeric, 2) AS std_healing_dealt_per10, - ROUND(AVG(block_per10)::numeric, 2) AS avg_damage_blocked_per10, - ROUND(STDDEV_SAMP(block_per10)::numeric, 2) AS std_damage_blocked_per10, - COUNT(*)::int AS total_players - FROM - limited - GROUP BY - player_hero - `; - - const statColumnMapping: Record< - string, - { avg: keyof BatchedBaselineRow; std: keyof BatchedBaselineRow } - > = { - eliminations: { - avg: "avg_eliminations_per10", - std: "std_eliminations_per10", - }, - deaths: { avg: "avg_deaths_per10", std: "std_deaths_per10" }, - hero_damage_dealt: { - avg: "avg_hero_damage_dealt_per10", - std: "std_hero_damage_dealt_per10", - }, - healing_dealt: { - avg: "avg_healing_dealt_per10", - std: "std_healing_dealt_per10", - }, - damage_blocked: { - avg: "avg_damage_blocked_per10", - std: "std_damage_blocked_per10", - }, - }; - - const result = new Map(); - for (const row of rows) { - for (const stat of BASELINE_STAT_COLUMNS) { - const mapping = statColumnMapping[stat]; - const avg = Number(row[mapping.avg]); - const std = Number(row[mapping.std]); - if (Number.isNaN(avg) || Number.isNaN(std)) continue; - - result.set(createBaselineKey(row.hero, stat), { - hero: row.hero, - stat_name: stat, - hero_avg_per10: avg, - hero_std_per10: std, - total_players: row.total_players, - }); - } - } - - return result; -} - -type HeroAgg = { - hero: string; - role: HeroRole; - mapIds: Set; - totalTime: number; - eliminations: number; - deaths: number; - heroDamageDealt: number; - healingDealt: number; - damageBlocked: number; -}; - -function aggregateStatsByHero(stats: PlayerStat[]): HeroAgg[] { - const heroData = new Map(); - - for (const stat of stats) { - if (!stat.MapDataId) continue; - if (stat.hero_time_played < MIN_HERO_TIME_SECONDS) continue; - - let data = heroData.get(stat.player_hero); - if (!data) { - const role = heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; - data = { - hero: stat.player_hero, - role, - mapIds: new Set(), - totalTime: 0, - eliminations: 0, - deaths: 0, - heroDamageDealt: 0, - healingDealt: 0, - damageBlocked: 0, - }; - heroData.set(stat.player_hero, data); - } - - if (!data.mapIds.has(stat.MapDataId)) { - data.mapIds.add(stat.MapDataId); - data.totalTime += stat.hero_time_played; - data.eliminations += stat.eliminations; - data.deaths += stat.deaths; - data.heroDamageDealt += stat.hero_damage_dealt; - data.healingDealt += stat.healing_dealt; - data.damageBlocked += stat.damage_blocked; - } - } - - return Array.from(heroData.values()) - .filter((h) => h.mapIds.size >= MIN_HERO_MAPS) - .sort((a, b) => b.totalTime - a.totalTime); -} - -function computeHeroZScores( - heroAggs: HeroAgg[], - baselines: Map -): ScoutingHeroPerformance[] { - return heroAggs.map((h) => { - const stats: HeroStatZScore[] = []; - const zScoresForComposite: { stat: ValidStatColumn; z: number }[] = []; - - for (const stat of BASELINE_STAT_COLUMNS) { - const per10 = calculatePer10( - stat === "eliminations" - ? h.eliminations - : stat === "deaths" - ? h.deaths - : stat === "hero_damage_dealt" - ? h.heroDamageDealt - : stat === "healing_dealt" - ? h.healingDealt - : h.damageBlocked, - h.totalTime - ); - - const baseline = baselines.get(createBaselineKey(h.hero, stat)); - const zScore = baseline - ? calculateZScoreFromBaseline(baseline, stat, per10) - : null; - - stats.push({ - stat, - per10, - zScore, - heroAvgPer10: baseline?.hero_avg_per10 ?? 0, - heroStdPer10: baseline?.hero_std_per10 ?? 0, - sampleSize: baseline?.total_players ?? 0, - }); - - if (zScore !== null) { - zScoresForComposite.push({ stat, z: zScore }); - } - } - - const weights = - h.role === "Tank" - ? { - eliminations: 0.2, - deaths: 0.3, - hero_damage_dealt: 0.2, - healing_dealt: 0, - damage_blocked: 0.3, - } - : h.role === "Support" - ? { - eliminations: 0.1, - deaths: 0.25, - hero_damage_dealt: 0.14, - healing_dealt: 0.35, - damage_blocked: 0, - } - : { - eliminations: 0.3, - deaths: 0.2, - hero_damage_dealt: 0.3, - healing_dealt: 0, - damage_blocked: 0, - }; - - let compositeZScore = 0; - for (const { stat, z } of zScoresForComposite) { - const weight = weights[stat as keyof typeof weights] ?? 0; - if (stat === "deaths") { - compositeZScore += z * weight; - } else { - compositeZScore += z * weight; - } - } - - return { - hero: h.hero, - role: h.role, - mapsPlayed: h.mapIds.size, - totalTimePlayed: h.totalTime, - stats, - compositeZScore, - }; - }); -} - -function buildAdvancedMetrics( - calculatedStats: { stat: string; value: number }[], - perMapStatGroups: PlayerStat[][] -): AdvancedMetrics { - const metrics: AdvancedMetrics = { - mvpScore: 0, - fletaDeadliftPercentage: 0, - firstPickPercentage: 0, - firstPickCount: 0, - firstDeathPercentage: 0, - firstDeathCount: 0, - fightReversalPercentage: 0, - killsPerUltimate: 0, - averageUltChargeTime: 0, - averageDroughtTime: 0, - duelWinratePercentage: 0, - consistencyScore: 0, - }; - - const counts: Record = {}; - - for (const cs of calculatedStats) { - switch (cs.stat) { - case CalculatedStatType.MVP_SCORE: - metrics.mvpScore += cs.value; - counts.mvp = (counts.mvp ?? 0) + 1; - break; - case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: - metrics.fletaDeadliftPercentage += cs.value; - counts.fleta = (counts.fleta ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_PERCENTAGE: - metrics.firstPickPercentage += cs.value; - counts.firstPick = (counts.firstPick ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_COUNT: - metrics.firstPickCount += cs.value; - break; - case CalculatedStatType.FIRST_DEATH_PERCENTAGE: - metrics.firstDeathPercentage += cs.value; - counts.firstDeath = (counts.firstDeath ?? 0) + 1; - break; - case CalculatedStatType.FIRST_DEATH_COUNT: - metrics.firstDeathCount += cs.value; - break; - case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: - metrics.fightReversalPercentage += cs.value; - counts.fightReversal = (counts.fightReversal ?? 0) + 1; - break; - case CalculatedStatType.KILLS_PER_ULTIMATE: - metrics.killsPerUltimate += cs.value; - counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: - metrics.averageUltChargeTime += cs.value; - counts.ultCharge = (counts.ultCharge ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_DROUGHT_TIME: - metrics.averageDroughtTime += cs.value; - counts.drought = (counts.drought ?? 0) + 1; - break; - case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: - metrics.duelWinratePercentage += cs.value; - counts.duel = (counts.duel ?? 0) + 1; - break; - } - } - - if (counts.mvp) metrics.mvpScore /= counts.mvp; - if (counts.fleta) metrics.fletaDeadliftPercentage /= counts.fleta; - if (counts.firstPick) metrics.firstPickPercentage /= counts.firstPick; - if (counts.firstDeath) metrics.firstDeathPercentage /= counts.firstDeath; - if (counts.fightReversal) - metrics.fightReversalPercentage /= counts.fightReversal; - if (counts.killsPerUlt) metrics.killsPerUltimate /= counts.killsPerUlt; - if (counts.ultCharge) metrics.averageUltChargeTime /= counts.ultCharge; - if (counts.drought) metrics.averageDroughtTime /= counts.drought; - if (counts.duel) metrics.duelWinratePercentage /= counts.duel; - - metrics.consistencyScore = computeConsistencyScore(perMapStatGroups); - - return metrics; -} - -function computeConsistencyScore(perMapStatGroups: PlayerStat[][]): number { - if (perMapStatGroups.length < 3) return 0; - - const per10Values: Record = { - elims: [], - deaths: [], - damage: [], - healing: [], - }; - - for (const mapStats of perMapStatGroups) { - let totalTime = 0; - let totalElims = 0; - let totalDeaths = 0; - let totalDamage = 0; - let totalHealing = 0; - - for (const s of mapStats) { - totalTime += s.hero_time_played; - totalElims += s.eliminations; - totalDeaths += s.deaths; - totalDamage += s.hero_damage_dealt; - totalHealing += s.healing_dealt; - } - - if (totalTime > 0) { - per10Values.elims.push(calculatePer10(totalElims, totalTime)); - per10Values.deaths.push(calculatePer10(totalDeaths, totalTime)); - per10Values.damage.push(calculatePer10(totalDamage, totalTime)); - per10Values.healing.push(calculatePer10(totalHealing, totalTime)); - } - } - - function mean(arr: number[]) { - return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; - } - - function stddev(arr: number[]) { - if (arr.length < 2) return 0; - const m = mean(arr); - return Math.sqrt( - arr.reduce((sum, v) => sum + (v - m) ** 2, 0) / (arr.length - 1) - ); - } - - const cvs = [ - per10Values.elims, - per10Values.deaths, - per10Values.damage, - per10Values.healing, - ] - .map((vals) => { - const m = mean(vals); - return m > 0 ? stddev(vals) / m : 0; - }) - .filter((cv) => cv > 0); - - if (cvs.length === 0) return 0; - const averageCV = mean(cvs); - return Math.max(0, Math.min(100, (1 - averageCV) * 100)); -} - -function buildKillPatterns( - kills: { - attacker_hero: string; - victim_hero: string; - event_ability: string; - }[], - deaths: { attacker_hero: string; victim_hero: string }[] -): KillPatterns { - const eliminatedCounts = new Map(); - for (const kill of kills) { - eliminatedCounts.set( - kill.victim_hero, - (eliminatedCounts.get(kill.victim_hero) ?? 0) + 1 - ); - } - - const diedToCounts = new Map(); - for (const death of deaths) { - diedToCounts.set( - death.attacker_hero, - (diedToCounts.get(death.attacker_hero) ?? 0) + 1 - ); - } - - const methodCounts = new Map(); - for (const kill of kills) { - const method = kill.event_ability || "Primary Fire"; - methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); - } - - return { - topHeroesEliminated: Array.from(eliminatedCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([hero, count]) => ({ hero, count })), - topHeroesDiedTo: Array.from(diedToCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([hero, count]) => ({ hero, count })), - killMethods: Array.from(methodCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 6) - .map(([method, count]) => ({ method, count })), - }; -} - -function buildRoleDistribution(stats: PlayerStat[]): RoleDistributionEntry[] { - const roleTime = new Map(); - let totalTime = 0; - - for (const stat of stats) { - const role = heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; - roleTime.set(role, (roleTime.get(role) ?? 0) + stat.hero_time_played); - totalTime += stat.hero_time_played; - } - - return Array.from(roleTime.entries()) - .map(([role, timePlayed]) => ({ - role, - timePlayed, - percentage: calculatePercentage(timePlayed, totalTime), - })) - .sort((a, b) => b.timePlayed - a.timePlayed); -} - -async function fetchCompetitiveMapWinrates( - playerName: string -): Promise { - const assignments = await prisma.scoutingHeroAssignment.findMany({ - where: { playerName: { equals: playerName, mode: "insensitive" } }, - select: { - team: true, - mapResult: { - select: { - id: true, - mapName: true, - mapType: true, - winner: true, - match: { - select: { - team1: true, - team2: true, - }, - }, - }, - }, - }, - }); - - const mapResults = new Map< - number, - { mapName: string; mapType: string; won: boolean } - >(); - - for (const assignment of assignments) { - if (mapResults.has(assignment.mapResult.id)) continue; - - const isTeam1 = assignment.team === "team1"; - const playerTeamAbbr = isTeam1 - ? assignment.mapResult.match.team1 - : assignment.mapResult.match.team2; - - mapResults.set(assignment.mapResult.id, { - mapName: assignment.mapResult.mapName, - mapType: assignment.mapResult.mapType, - won: assignment.mapResult.winner === playerTeamAbbr, - }); - } - - const byMapType = new Map(); - const byMap = new Map< - string, - { mapType: string; wins: number; losses: number } - >(); - - for (const [, result] of mapResults) { - const typeEntry = byMapType.get(result.mapType) ?? { - wins: 0, - losses: 0, - }; - if (result.won) typeEntry.wins++; - else typeEntry.losses++; - byMapType.set(result.mapType, typeEntry); - - const mapEntry = byMap.get(result.mapName) ?? { - mapType: result.mapType, - wins: 0, - losses: 0, - }; - if (result.won) mapEntry.wins++; - else mapEntry.losses++; - byMap.set(result.mapName, mapEntry); - } - - return { - byMapType: Array.from(byMapType.entries()) - .map(([mapType, data]) => ({ - mapType, - wins: data.wins, - losses: data.losses, - played: data.wins + data.losses, - winRate: - data.wins + data.losses > 0 - ? (data.wins / (data.wins + data.losses)) * 100 - : 0, - })) - .sort((a, b) => b.played - a.played), - byMap: Array.from(byMap.entries()) - .map(([mapName, data]) => ({ - mapName, - mapType: data.mapType, - wins: data.wins, - losses: data.losses, - played: data.wins + data.losses, - winRate: - data.wins + data.losses > 0 - ? (data.wins / (data.wins + data.losses)) * 100 - : 0, - })) - .sort((a, b) => b.played - a.played), - }; -} - -async function getPlayerScoutingAnalyticsFn( - playerName: string -): Promise { - const competitiveMapWinrates = await fetchCompetitiveMapWinrates(playerName); - - const playerStatCheck = await prisma.playerStat.findFirst({ - where: { player_name: { equals: playerName, mode: "insensitive" } }, - select: { id: true }, - }); - - if (!playerStatCheck) { - return { - scrimData: null, - competitiveMapWinrates, - scrimMapWinrates: null, - strengths: [], - weaknesses: [], - }; - } - - const allStats = removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "player_name" ILIKE ${playerName} - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."player_name" ILIKE ${playerName}` - ); - - if (allStats.length === 0) { - return { - scrimData: null, - competitiveMapWinrates, - scrimMapWinrates: null, - strengths: [], - weaknesses: [], - }; - } - - const mapDataIds = [ - ...new Set( - allStats.map((s) => s.MapDataId).filter((id): id is number => id !== null) - ), - ]; - - const [kills, deaths, calculatedStats] = await Promise.all([ - prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIds }, - attacker_name: { equals: playerName, mode: "insensitive" }, - }, - select: { - attacker_hero: true, - victim_hero: true, - event_ability: true, - }, - }), - prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIds }, - victim_name: { equals: playerName, mode: "insensitive" }, - }, - select: { attacker_hero: true, victim_hero: true }, - }), - prisma.calculatedStat.findMany({ - where: { - MapDataId: { in: mapDataIds }, - playerName: { equals: playerName, mode: "insensitive" }, - }, - select: { stat: true, value: true, MapDataId: true }, - }), - ]); - - const heroAggs = aggregateStatsByHero(allStats); - const heroNames = heroAggs - .map((h) => h.hero) - .filter((h): h is HeroName => h in heroRoleMapping); - const baselines = await fetchBaselinesByHeroes(heroNames); - const heroes = computeHeroZScores(heroAggs, baselines); - - const perMapStatGroups: PlayerStat[][] = []; - const statsByMap = new Map(); - for (const stat of allStats) { - if (!stat.MapDataId) continue; - const existing = statsByMap.get(stat.MapDataId) ?? []; - existing.push(stat); - statsByMap.set(stat.MapDataId, existing); - } - for (const [, group] of statsByMap) { - perMapStatGroups.push(group); - } - - const advancedMetrics = buildAdvancedMetrics( - calculatedStats, - perMapStatGroups - ); - - const killPatterns = buildKillPatterns(kills, deaths); - const roleDistribution = buildRoleDistribution(allStats); - - let totalTime = 0; - let totalElims = 0; - let totalDeaths = 0; - let totalHeroDamage = 0; - let totalHealing = 0; - let totalShotsFired = 0; - let totalShotsHit = 0; - let totalCriticalHits = 0; - let totalScopedShots = 0; - let totalScopedShotsHit = 0; - - for (const stat of allStats) { - totalTime += stat.hero_time_played; - totalElims += stat.eliminations; - totalDeaths += stat.deaths; - totalHeroDamage += stat.hero_damage_dealt; - totalHealing += stat.healing_dealt; - totalShotsFired += stat.shots_fired; - totalShotsHit += stat.shots_hit; - totalCriticalHits += stat.critical_hits; - totalScopedShots += stat.scoped_shots; - totalScopedShotsHit += stat.scoped_shots_hit; - } - - const scrimData: ScrimData = { - available: true, - mapsPlayed: mapDataIds.length, - totalTimePlayed: totalTime, - heroes, - advancedMetrics, - killPatterns, - roleDistribution, - accuracy: { - weaponAccuracy: calculatePercentage(totalShotsHit, totalShotsFired), - criticalHitAccuracy: calculatePercentage( - totalCriticalHits, - totalShotsHit - ), - scopedAccuracy: calculatePercentage( - totalScopedShotsHit, - totalScopedShots - ), - }, - kdRatio: totalDeaths > 0 ? totalElims / totalDeaths : totalElims, - eliminationsPer10: calculatePer10(totalElims, totalTime), - deathsPer10: calculatePer10(totalDeaths, totalTime), - heroDamagePer10: calculatePer10(totalHeroDamage, totalTime), - healingDealtPer10: calculatePer10(totalHealing, totalTime), - }; - - const analytics: PlayerScoutingAnalytics = { - scrimData, - competitiveMapWinrates, - scrimMapWinrates: null, - strengths: [], - weaknesses: [], - }; - - const insights = generatePlayerInsights(analytics); - analytics.strengths = insights.strengths; - analytics.weaknesses = insights.weaknesses; - - return analytics; -} - -export const getPlayerScoutingAnalytics = cache(getPlayerScoutingAnalyticsFn); - -function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { - strengths: InsightItem[]; - weaknesses: InsightItem[]; -} { - const strengths: InsightItem[] = []; - const weaknesses: InsightItem[] = []; - - if (analytics.scrimData) { - const { heroes, advancedMetrics } = analytics.scrimData; - - for (const hero of heroes) { - if (hero.compositeZScore > 1.0) { - strengths.push({ - category: "hero", - label: `Elite ${hero.hero}`, - detail: `Composite z-score of +${hero.compositeZScore.toFixed(1)}σ across ${hero.mapsPlayed} maps`, - value: hero.compositeZScore, - }); - } else if (hero.compositeZScore < -0.5 && hero.mapsPlayed >= 3) { - weaknesses.push({ - category: "hero", - label: `Below average ${hero.hero}`, - detail: `Composite z-score of ${hero.compositeZScore.toFixed(1)}σ across ${hero.mapsPlayed} maps`, - value: hero.compositeZScore, - }); - } - } - - for (const hero of heroes) { - for (const stat of hero.stats) { - if ( - stat.zScore !== null && - stat.zScore > 1.5 && - stat.sampleSize >= 10 - ) { - const statLabel = stat.stat.replace(/_/g, " "); - strengths.push({ - category: "stat", - label: `Exceptional ${statLabel}`, - detail: `+${stat.zScore.toFixed(1)}σ on ${hero.hero} (${stat.per10.toFixed(0)}/10 vs avg ${stat.heroAvgPer10.toFixed(0)}/10)`, - value: stat.zScore, - }); - } else if ( - stat.zScore !== null && - stat.zScore < -1.5 && - stat.sampleSize >= 10 - ) { - const statLabel = stat.stat.replace(/_/g, " "); - weaknesses.push({ - category: "stat", - label: `Weak ${statLabel}`, - detail: `${stat.zScore.toFixed(1)}σ on ${hero.hero} (${stat.per10.toFixed(0)}/10 vs avg ${stat.heroAvgPer10.toFixed(0)}/10)`, - value: stat.zScore, - }); - } - } - } - - if (advancedMetrics.firstPickPercentage > 25) { - strengths.push({ - category: "combat", - label: "High-impact opener", - detail: `${advancedMetrics.firstPickPercentage.toFixed(0)}% first pick rate with ${advancedMetrics.firstPickCount} total first picks`, - value: advancedMetrics.firstPickPercentage, - }); - } - - if (advancedMetrics.firstDeathPercentage > 30) { - weaknesses.push({ - category: "combat", - label: "Frequently caught out", - detail: `${advancedMetrics.firstDeathPercentage.toFixed(0)}% first death rate (${advancedMetrics.firstDeathCount} first deaths)`, - value: advancedMetrics.firstDeathPercentage, - }); - } - - if (advancedMetrics.consistencyScore > 80) { - strengths.push({ - category: "stat", - label: "Highly consistent", - detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating across maps`, - value: advancedMetrics.consistencyScore, - }); - } else if ( - advancedMetrics.consistencyScore > 0 && - advancedMetrics.consistencyScore < 50 - ) { - weaknesses.push({ - category: "stat", - label: "Inconsistent performer", - detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating — high variance across maps`, - value: -advancedMetrics.consistencyScore, - }); - } - - if (heroes.length >= 2) { - const delta = heroes[0].compositeZScore - heroes[1].compositeZScore; - if (delta > 1.5) { - weaknesses.push({ - category: "hero", - label: "Narrow hero pool", - detail: `${delta.toFixed(1)}σ drop-off from ${heroes[0].hero} to ${heroes[1].hero}`, - value: -delta, - }); - } - } - - if (advancedMetrics.fightReversalPercentage > 15) { - strengths.push({ - category: "combat", - label: "Clutch player", - detail: `${advancedMetrics.fightReversalPercentage.toFixed(0)}% fight reversal rate`, - value: advancedMetrics.fightReversalPercentage, - }); - } - } - - for (const entry of analytics.competitiveMapWinrates.byMapType) { - if (entry.winRate > 65 && entry.played >= 5) { - strengths.push({ - category: "map", - label: `Dominant on ${entry.mapType}`, - detail: `${entry.winRate.toFixed(0)}% win rate across ${entry.played} maps`, - value: entry.winRate, - }); - } else if (entry.winRate < 35 && entry.played >= 3) { - weaknesses.push({ - category: "map", - label: `Struggles on ${entry.mapType}`, - detail: `${entry.winRate.toFixed(0)}% win rate across ${entry.played} maps`, - value: -entry.winRate, - }); - } - } - - strengths.sort((a, b) => b.value - a.value); - weaknesses.sort((a, b) => a.value - b.value); - - return { - strengths: strengths.slice(0, 4), - weaknesses: weaknesses.slice(0, 4), - }; -} diff --git a/src/data/player-scouting-dto.ts b/src/data/player-scouting-dto.ts deleted file mode 100644 index 04b267fa9..000000000 --- a/src/data/player-scouting-dto.ts +++ /dev/null @@ -1,357 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { cache } from "react"; - -export type ScoutingPlayerSummary = { - id: number; - name: string; - team: string; - role: string; - region: string; - status: string; - slug: string; -}; - -export type HeroFrequency = { - hero: string; - mapCount: number; -}; - -export type TournamentRecord = { - tournamentTitle: string; - teamName: string; - role: string; - wins: number; - losses: number; - winRate: number; - matches: TournamentMatchEntry[]; -}; - -export type TournamentMatchEntry = { - date: Date; - opponent: string; - opponentFullName: string; - teamScore: number | null; - opponentScore: number | null; - result: "win" | "loss"; - heroesPlayed: string[]; -}; - -export type PlayerProfile = { - id: number; - name: string; - team: string; - status: string; - role: string; - country: string; - signatureHeroes: string[]; - winnings: number; - region: string; - playerUrl: string; - slug: string; - heroFrequencies: HeroFrequency[]; - competitiveMapCount: number; - tournamentRecords: TournamentRecord[]; - totalTournaments: number; -}; - -function extractSlug(playerUrl: string): string { - const parts = playerUrl.split("/"); - return parts[parts.length - 1] ?? ""; -} - -async function getScoutingPlayersFn(): Promise { - const players = await prisma.scoutingPlayer.findMany({ - select: { - id: true, - name: true, - team: true, - role: true, - region: true, - status: true, - playerUrl: true, - }, - orderBy: { name: "asc" }, - }); - - return players.map((p) => ({ - id: p.id, - name: p.name, - team: p.team, - role: p.role, - region: p.region, - status: p.status, - slug: extractSlug(p.playerUrl), - })); -} - -export const getScoutingPlayers = cache(getScoutingPlayersFn); - -async function getPlayerProfileFn(slug: string): Promise { - const playerUrl = `https://liquipedia.net/overwatch/${slug}`; - - const player = await prisma.scoutingPlayer.findUnique({ - where: { playerUrl }, - }); - - if (!player) return null; - - const [heroAssignments, rosterEntries] = await Promise.all([ - prisma.scoutingHeroAssignment.findMany({ - where: { playerName: { equals: player.name, mode: "insensitive" } }, - select: { - heroName: true, - mapResultId: true, - team: true, - mapResult: { - select: { - mapName: true, - match: { - select: { - matchDate: true, - team1: true, - team1FullName: true, - team2: true, - team2FullName: true, - team1Score: true, - team2Score: true, - winner: true, - tournament: { select: { title: true } }, - }, - }, - }, - }, - }, - }), - prisma.scoutingRosterPlayer.findMany({ - where: { displayName: { equals: player.name, mode: "insensitive" } }, - select: { - role: true, - didNotPlay: true, - roster: { - select: { - teamName: true, - tournament: { - select: { - id: true, - title: true, - }, - }, - }, - }, - }, - }), - ]); - - const heroCountMap = new Map>(); - for (const assignment of heroAssignments) { - const existing = heroCountMap.get(assignment.heroName) ?? new Set(); - existing.add(assignment.mapResultId); - heroCountMap.set(assignment.heroName, existing); - } - - const heroFrequencies: HeroFrequency[] = Array.from(heroCountMap.entries()) - .map(([hero, mapIds]) => ({ hero, mapCount: mapIds.size })) - .sort((a, b) => b.mapCount - a.mapCount); - - const competitiveMapCount = new Set(heroAssignments.map((a) => a.mapResultId)) - .size; - - const tournamentMap = new Map< - string, - { - tournamentTitle: string; - teamName: string; - role: string; - matchMap: Map< - string, - { - date: Date; - opponent: string; - opponentFullName: string; - teamScore: number | null; - opponentScore: number | null; - result: "win" | "loss"; - heroesPlayed: Set; - } - >; - } - >(); - - const rosterTournamentIds = new Set(); - const rosterTeamsByTournament = new Map< - number, - { teamName: string; tournamentTitle: string; role: string } - >(); - - for (const entry of rosterEntries) { - const tournamentId = entry.roster.tournament.id; - const tournamentTitle = entry.roster.tournament.title; - const teamName = entry.roster.teamName; - - rosterTournamentIds.add(tournamentId); - rosterTeamsByTournament.set(tournamentId, { - teamName, - tournamentTitle, - role: entry.role, - }); - - if (!tournamentMap.has(tournamentTitle)) { - tournamentMap.set(tournamentTitle, { - tournamentTitle, - teamName, - role: entry.role, - matchMap: new Map(), - }); - } - } - - if (rosterTournamentIds.size > 0) { - const rosterMatches = await prisma.scoutingMatch.findMany({ - where: { - tournamentId: { in: Array.from(rosterTournamentIds) }, - }, - select: { - matchDate: true, - team1: true, - team1FullName: true, - team2: true, - team2FullName: true, - team1Score: true, - team2Score: true, - winner: true, - tournamentId: true, - tournament: { select: { title: true } }, - }, - orderBy: { matchDate: "desc" }, - }); - - for (const match of rosterMatches) { - const rosterInfo = rosterTeamsByTournament.get(match.tournamentId); - if (!rosterInfo) continue; - - const isTeam1 = - match.team1 === rosterInfo.teamName || - match.team1FullName === rosterInfo.teamName; - const isTeam2 = - match.team2 === rosterInfo.teamName || - match.team2FullName === rosterInfo.teamName; - if (!isTeam1 && !isTeam2) continue; - - const onTeam1Side = isTeam1; - const teamAbbr = onTeam1Side ? match.team1 : match.team2; - - const tournament = tournamentMap.get(rosterInfo.tournamentTitle); - if (!tournament) continue; - - const matchKey = `${match.team1}-${match.team2}-${match.matchDate.toISOString()}`; - if (!tournament.matchMap.has(matchKey)) { - const won = match.winner === teamAbbr; - tournament.matchMap.set(matchKey, { - date: match.matchDate, - opponent: onTeam1Side ? match.team2 : match.team1, - opponentFullName: onTeam1Side - ? match.team2FullName - : match.team1FullName, - teamScore: onTeam1Side ? match.team1Score : match.team2Score, - opponentScore: onTeam1Side ? match.team2Score : match.team1Score, - result: won ? "win" : "loss", - heroesPlayed: new Set(), - }); - } - } - } - - for (const assignment of heroAssignments) { - const match = assignment.mapResult.match; - const tournamentTitle = match.tournament.title; - const isTeam1 = assignment.team === "team1"; - const teamAbbr = isTeam1 ? match.team1 : match.team2; - - if (!tournamentMap.has(tournamentTitle)) { - const rosterEntry = rosterEntries.find( - (r) => r.roster.tournament.title === tournamentTitle - ); - tournamentMap.set(tournamentTitle, { - tournamentTitle, - teamName: teamAbbr, - role: rosterEntry?.role ?? player.role, - matchMap: new Map(), - }); - } - - const tournament = tournamentMap.get(tournamentTitle)!; - const matchKey = `${match.team1}-${match.team2}-${match.matchDate.toISOString()}`; - - if (!tournament.matchMap.has(matchKey)) { - const won = match.winner === teamAbbr; - tournament.matchMap.set(matchKey, { - date: match.matchDate, - opponent: isTeam1 ? match.team2 : match.team1, - opponentFullName: isTeam1 ? match.team2FullName : match.team1FullName, - teamScore: isTeam1 ? match.team1Score : match.team2Score, - opponentScore: isTeam1 ? match.team2Score : match.team1Score, - result: won ? "win" : "loss", - heroesPlayed: new Set(), - }); - } - - tournament.matchMap.get(matchKey)!.heroesPlayed.add(assignment.heroName); - } - - const tournamentRecords: TournamentRecord[] = Array.from( - tournamentMap.values() - ) - .map((t) => { - const matches: TournamentMatchEntry[] = Array.from(t.matchMap.values()) - .map((m) => ({ - date: m.date, - opponent: m.opponent, - opponentFullName: m.opponentFullName, - teamScore: m.teamScore, - opponentScore: m.opponentScore, - result: m.result, - heroesPlayed: Array.from(m.heroesPlayed), - })) - .sort((a, b) => b.date.getTime() - a.date.getTime()); - - const wins = matches.filter((m) => m.result === "win").length; - const losses = matches.length - wins; - - return { - tournamentTitle: t.tournamentTitle, - teamName: t.teamName, - role: t.role, - wins, - losses, - winRate: matches.length > 0 ? (wins / matches.length) * 100 : 0, - matches, - }; - }) - .sort((a, b) => { - const aDate = a.matches[0]?.date.getTime() ?? 0; - const bDate = b.matches[0]?.date.getTime() ?? 0; - return bDate - aDate; - }); - - return { - id: player.id, - name: player.name, - team: player.team, - status: player.status, - role: player.role, - country: player.country, - signatureHeroes: player.signatureHeroes, - winnings: player.winnings, - region: player.region, - playerUrl: player.playerUrl, - slug: extractSlug(player.playerUrl), - heroFrequencies, - competitiveMapCount, - tournamentRecords, - totalTournaments: tournamentRecords.length, - }; -} - -export const getPlayerProfile = cache(getPlayerProfileFn); diff --git a/src/data/replay-dto.ts b/src/data/replay-dto.ts deleted file mode 100644 index d429685f7..000000000 --- a/src/data/replay-dto.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { - getControlSubMapNames, - isControlMap, -} from "@/lib/map-calibration/control-map-index"; -import { - loadCalibration, - type LoadedCalibration, -} from "@/lib/map-calibration/load-calibration"; -import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; - -export type PositionSample = { - t: number; - playerName: string; - playerTeam: string; - hero: string; - x: number; - z: number; -}; - -export type KillDisplayEvent = { - type: "kill"; - t: number; - attackerName: string; - attackerTeam: string; - attackerHero: string; - victimName: string; - victimTeam: string; - victimHero: string; - ability: string; - damage: number; -}; - -export type UltDisplayEvent = { - type: "ult_start" | "ult_end" | "ult_charged"; - t: number; - playerName: string; - playerTeam: string; - playerHero: string; - ultimateId: number; -}; - -export type HeroSwapDisplayEvent = { - type: "hero_swap"; - t: number; - playerName: string; - playerTeam: string; - playerHero: string; - previousHero: string; -}; - -export type RoundDisplayEvent = { - type: "round_start" | "round_end"; - t: number; - roundNumber: number; - objectiveIndex: number; -}; - -export type DisplayEvent = - | KillDisplayEvent - | UltDisplayEvent - | HeroSwapDisplayEvent - | RoundDisplayEvent; - -export type ReplayCalibration = { - calibrations: Record; - mapName: string; - mapType: string; - roundStarts: { matchTime: number; objectiveIndex: number }[]; -}; - -export type ReplayData = - | { - type: "ready"; - positionSamples: PositionSample[]; - displayEvents: DisplayEvent[]; - calibration: ReplayCalibration; - team1Name: string; - team2Name: string; - } - | { type: "no_calibration" } - | { type: "no_coordinates" }; - -function pushSample( - samples: PositionSample[], - t: number, - playerName: string, - playerTeam: string, - hero: string, - x: number | null, - z: number | null -) { - if (x != null && z != null) { - samples.push({ t, playerName, playerTeam, hero, x, z }); - } -} - -export async function getReplayData(mapDataId: number): Promise { - const matchStart = await prisma.matchStart.findFirst({ - where: { MapDataId: mapDataId }, - select: { - map_name: true, - map_type: true, - team_1_name: true, - team_2_name: true, - }, - }); - - if (!matchStart) return { type: "no_calibration" }; - - const [ - kills, - damage, - healing, - ability1, - ability2, - ultStarts, - ultEnds, - ultCharged, - heroSwaps, - roundStarts, - roundEnds, - ] = await Promise.all([ - prisma.kill.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - attacker_name: true, - attacker_team: true, - attacker_hero: true, - attacker_x: true, - attacker_z: true, - victim_name: true, - victim_team: true, - victim_hero: true, - victim_x: true, - victim_z: true, - event_ability: true, - event_damage: true, - }, - }), - prisma.damage.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - attacker_name: true, - attacker_team: true, - attacker_hero: true, - attacker_x: true, - attacker_z: true, - victim_name: true, - victim_team: true, - victim_hero: true, - victim_x: true, - victim_z: true, - }, - }), - prisma.healing.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - healer_name: true, - healer_team: true, - healer_hero: true, - healer_x: true, - healer_z: true, - healee_name: true, - healee_team: true, - healee_hero: true, - healee_x: true, - healee_z: true, - }, - }), - prisma.ability1Used.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - }, - }), - prisma.ultimateStart.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - ultimate_id: true, - }, - }), - prisma.ultimateEnd.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - ultimate_id: true, - }, - }), - prisma.ultimateCharged.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - ultimate_id: true, - }, - }), - prisma.heroSwap.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - previous_hero: true, - }, - }), - prisma.roundStart.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - round_number: true, - objective_index: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.roundEnd.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - round_number: true, - objective_index: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - const hasCoords = - kills.some((e) => e.attacker_x != null || e.victim_x != null) || - damage.some((e) => e.attacker_x != null || e.victim_x != null) || - healing.some((e) => e.healer_x != null || e.healee_x != null) || - ability1.some((e) => e.player_x != null) || - ability2.some((e) => e.player_x != null) || - ultStarts.some((e) => e.player_x != null) || - ultEnds.some((e) => e.player_x != null); - - if (!hasCoords) return { type: "no_coordinates" }; - - const positionSamples: PositionSample[] = []; - - for (const k of kills) { - pushSample( - positionSamples, - k.match_time, - k.attacker_name, - k.attacker_team, - k.attacker_hero, - k.attacker_x, - k.attacker_z - ); - pushSample( - positionSamples, - k.match_time, - k.victim_name, - k.victim_team, - k.victim_hero, - k.victim_x, - k.victim_z - ); - } - - for (const d of damage) { - pushSample( - positionSamples, - d.match_time, - d.attacker_name, - d.attacker_team, - d.attacker_hero, - d.attacker_x, - d.attacker_z - ); - pushSample( - positionSamples, - d.match_time, - d.victim_name, - d.victim_team, - d.victim_hero, - d.victim_x, - d.victim_z - ); - } - - for (const h of healing) { - pushSample( - positionSamples, - h.match_time, - h.healer_name, - h.healer_team, - h.healer_hero, - h.healer_x, - h.healer_z - ); - pushSample( - positionSamples, - h.match_time, - h.healee_name, - h.healee_team, - h.healee_hero, - h.healee_x, - h.healee_z - ); - } - - for (const a of ability1) { - pushSample( - positionSamples, - a.match_time, - a.player_name, - a.player_team, - a.player_hero, - a.player_x, - a.player_z - ); - } - - for (const a of ability2) { - pushSample( - positionSamples, - a.match_time, - a.player_name, - a.player_team, - a.player_hero, - a.player_x, - a.player_z - ); - } - - for (const u of ultStarts) { - pushSample( - positionSamples, - u.match_time, - u.player_name, - u.player_team, - u.player_hero, - u.player_x, - u.player_z - ); - } - - for (const u of ultEnds) { - pushSample( - positionSamples, - u.match_time, - u.player_name, - u.player_team, - u.player_hero, - u.player_x, - u.player_z - ); - } - - positionSamples.sort((a, b) => a.t - b.t); - - const displayEvents: DisplayEvent[] = []; - - for (const k of kills) { - displayEvents.push({ - type: "kill", - t: k.match_time, - attackerName: k.attacker_name, - attackerTeam: k.attacker_team, - attackerHero: k.attacker_hero, - victimName: k.victim_name, - victimTeam: k.victim_team, - victimHero: k.victim_hero, - ability: k.event_ability, - damage: k.event_damage, - }); - } - - for (const u of ultStarts) { - displayEvents.push({ - type: "ult_start", - t: u.match_time, - playerName: u.player_name, - playerTeam: u.player_team, - playerHero: u.player_hero, - ultimateId: u.ultimate_id, - }); - } - - for (const u of ultEnds) { - displayEvents.push({ - type: "ult_end", - t: u.match_time, - playerName: u.player_name, - playerTeam: u.player_team, - playerHero: u.player_hero, - ultimateId: u.ultimate_id, - }); - } - - for (const u of ultCharged) { - displayEvents.push({ - type: "ult_charged", - t: u.match_time, - playerName: u.player_name, - playerTeam: u.player_team, - playerHero: u.player_hero, - ultimateId: u.ultimate_id, - }); - } - - for (const h of heroSwaps) { - displayEvents.push({ - type: "hero_swap", - t: h.match_time, - playerName: h.player_name, - playerTeam: h.player_team, - playerHero: h.player_hero, - previousHero: h.previous_hero, - }); - } - - for (const r of roundStarts) { - displayEvents.push({ - type: "round_start", - t: r.match_time, - roundNumber: r.round_number, - objectiveIndex: r.objective_index, - }); - } - - for (const r of roundEnds) { - displayEvents.push({ - type: "round_end", - t: r.match_time, - roundNumber: r.round_number, - objectiveIndex: r.objective_index, - }); - } - - displayEvents.sort((a, b) => a.t - b.t); - - const calibration = await loadReplayCalibration( - matchStart.map_name, - matchStart.map_type, - mapDataId, - roundStarts - ); - - if (!calibration) return { type: "no_calibration" }; - - return { - type: "ready", - positionSamples, - displayEvents, - calibration, - team1Name: matchStart.team_1_name, - team2Name: matchStart.team_2_name, - }; -} - -async function loadReplayCalibration( - mapName: string, - mapType: $Enums.MapType, - mapDataId: number, - roundStarts: { match_time: number; objective_index: number }[] -): Promise { - const calibrations: Record = {}; - - if (mapType === $Enums.MapType.Control && isControlMap(mapName)) { - const allSubMapNames = getControlSubMapNames(mapName); - for (const subMapName of allSubMapNames) { - const cal = await loadCalibration(subMapName); - if (cal) calibrations[subMapName] = cal; - } - - if (Object.keys(calibrations).length === 0) return null; - - return { - calibrations, - mapName, - mapType, - roundStarts: roundStarts.map((r) => ({ - matchTime: r.match_time, - objectiveIndex: r.objective_index, - })), - }; - } - - const cal = await loadCalibration(mapName); - if (!cal) return null; - - calibrations[mapName] = cal; - - return { - calibrations, - mapName, - mapType, - roundStarts: [], - }; -} diff --git a/src/data/rotation-death-dto.ts b/src/data/rotation-death-dto.ts deleted file mode 100644 index a9e3dd3f5..000000000 --- a/src/data/rotation-death-dto.ts +++ /dev/null @@ -1,277 +0,0 @@ -import "server-only"; - -import { - detectRotationDeaths, - summarizeByPlayer, - type DamageEvent, - type NearbyPlayer, - type RotationDeathAnalysis, -} from "@/lib/replay/rotation-death-detection"; -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import { groupEventsIntoFights, mercyRezToKillEvent } from "@/lib/utils"; -import prisma from "@/lib/prisma"; -import { cache } from "react"; - -type PositionEvent = { - match_time: number; - playerName: string; - playerTeam: string; - hero: string; - x: number; - z: number; -}; - -const NEARBY_WINDOW_SEC = 10; - -function findNearbyPlayers( - killTime: number, - attackerName: string, - victimName: string, - positionEvents: PositionEvent[] -): NearbyPlayer[] { - const windowStart = killTime - NEARBY_WINDOW_SEC; - const closest = new Map(); - - let lo = 0; - let hi = positionEvents.length; - while (lo < hi) { - const mid = (lo + hi) >>> 1; - if (positionEvents[mid].match_time < windowStart) { - lo = mid + 1; - } else { - hi = mid; - } - } - - for (let i = lo; i < positionEvents.length; i++) { - const e = positionEvents[i]; - if (e.match_time > killTime) break; - if (e.playerName === attackerName || e.playerName === victimName) continue; - - const key = `${e.playerName}::${e.playerTeam}`; - const dt = killTime - e.match_time; - const existing = closest.get(key); - if (!existing || dt < existing.dt) { - closest.set(key, { - player: { - playerName: e.playerName, - playerTeam: e.playerTeam, - hero: e.hero, - x: e.x, - z: e.z, - }, - dt, - }); - } - } - - return Array.from(closest.values()).map((v) => v.player); -} - -function pushPos( - arr: PositionEvent[], - matchTime: number, - name: string, - team: string, - hero: string, - x: number | null, - z: number | null -) { - if (x != null && z != null) { - arr.push({ - match_time: matchTime, - playerName: name, - playerTeam: team, - hero, - x, - z, - }); - } -} - -export const getRotationDeathAnalysis = cache( - async (mapDataId: number): Promise => { - const resolvedId = await resolveMapDataId(mapDataId); - const [kills, mercyRezzes, damage] = await Promise.all([ - prisma.kill.findMany({ where: { MapDataId: resolvedId } }), - prisma.mercyRez.findMany({ where: { MapDataId: resolvedId } }), - prisma.damage.findMany({ - where: { MapDataId: resolvedId }, - select: { - match_time: true, - attacker_name: true, - attacker_team: true, - attacker_hero: true, - attacker_x: true, - attacker_z: true, - victim_name: true, - victim_team: true, - victim_hero: true, - victim_x: true, - victim_z: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - if (kills.length === 0) return null; - - const damageEvents: DamageEvent[] = damage; - - const fightEvents = [ - ...kills, - ...mercyRezzes.map(mercyRezToKillEvent), - ].sort((a, b) => a.match_time - b.match_time); - - const fights = groupEventsIntoFights(fightEvents); - const results = detectRotationDeaths(fights, damageEvents); - const rotationDeaths = results.filter((r) => r.isRotationDeath); - - if (rotationDeaths.length > 0) { - const [healing, ability1, ability2] = await Promise.all([ - prisma.healing.findMany({ - where: { MapDataId: resolvedId }, - select: { - match_time: true, - healer_name: true, - healer_team: true, - healer_hero: true, - healer_x: true, - healer_z: true, - healee_name: true, - healee_team: true, - healee_hero: true, - healee_x: true, - healee_z: true, - }, - }), - prisma.ability1Used.findMany({ - where: { MapDataId: resolvedId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: resolvedId }, - select: { - match_time: true, - player_name: true, - player_team: true, - player_hero: true, - player_x: true, - player_z: true, - }, - }), - ]); - - const positionEvents: PositionEvent[] = []; - - for (const d of damage) { - pushPos( - positionEvents, - d.match_time, - d.attacker_name, - d.attacker_team, - d.attacker_hero, - d.attacker_x, - d.attacker_z - ); - pushPos( - positionEvents, - d.match_time, - d.victim_name, - d.victim_team, - d.victim_hero, - d.victim_x, - d.victim_z - ); - } - for (const k of kills) { - pushPos( - positionEvents, - k.match_time, - k.attacker_name, - k.attacker_team, - k.attacker_hero, - k.attacker_x, - k.attacker_z - ); - pushPos( - positionEvents, - k.match_time, - k.victim_name, - k.victim_team, - k.victim_hero, - k.victim_x, - k.victim_z - ); - } - for (const h of healing) { - pushPos( - positionEvents, - h.match_time, - h.healer_name, - h.healer_team, - h.healer_hero, - h.healer_x, - h.healer_z - ); - pushPos( - positionEvents, - h.match_time, - h.healee_name, - h.healee_team, - h.healee_hero, - h.healee_x, - h.healee_z - ); - } - for (const a of ability1) { - pushPos( - positionEvents, - a.match_time, - a.player_name, - a.player_team, - a.player_hero, - a.player_x, - a.player_z - ); - } - for (const a of ability2) { - pushPos( - positionEvents, - a.match_time, - a.player_name, - a.player_team, - a.player_hero, - a.player_x, - a.player_z - ); - } - - positionEvents.sort((a, b) => a.match_time - b.match_time); - - for (const rd of rotationDeaths) { - rd.nearbyPlayers = findNearbyPlayers( - rd.kill.match_time, - rd.kill.attacker_name, - rd.kill.victim_name, - positionEvents - ); - } - } - - return { - mapDataId, - rotationDeaths, - totalKills: kills.length, - totalFights: fights.length, - playerSummaries: summarizeByPlayer(results), - }; - } -); diff --git a/src/data/scouting-dto.ts b/src/data/scouting-dto.ts deleted file mode 100644 index 9b1b8ed08..000000000 --- a/src/data/scouting-dto.ts +++ /dev/null @@ -1,350 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { MapType, Prisma } from "@prisma/client"; -import { cache } from "react"; - -export type ScoutingTeam = { - abbreviation: string; - fullName: string; - matchCount: number; - winCount: number; -}; - -export type MatchResult = "win" | "loss"; - -export type ScoutingTeamOverview = { - totalMatches: number; - wins: number; - losses: number; - winRate: number; - weightedWinRate: number; - recentForm: MatchResult[]; -}; - -export type HeroBanEntry = { - hero: string; - rawCount: number; - weightedCount: number; -}; - -export type ScoutingHeroBans = { - bansByTeam: HeroBanEntry[]; - bansAgainstTeam: HeroBanEntry[]; -}; - -export type MapPerformanceEntry = { - name: string; - played: number; - won: number; - winRate: number; - weightedWinRate: number; -}; - -export type ScoutingMapAnalysis = { - byMap: MapPerformanceEntry[]; - byMapType: (MapPerformanceEntry & { mapType: MapType })[]; -}; - -export type ScoutingMatchHistoryEntry = { - date: Date; - opponent: string; - opponentFullName: string; - teamScore: number | null; - opponentScore: number | null; - result: MatchResult; - tournament: string; -}; - -export type ScoutingRecommendation = { - name: string; - reason: string; - weightedWinRate: number; - sampleSize: number; -}; - -export type ScoutingRecommendations = { - suggestedBans: ScoutingRecommendation[]; - suggestedMapPicks: ScoutingRecommendation[]; - suggestedMapAvoids: ScoutingRecommendation[]; -}; - -export type ScoutingTeamProfile = { - team: { abbreviation: string; fullName: string }; - overview: ScoutingTeamOverview; - heroBans: ScoutingHeroBans; - mapAnalysis: ScoutingMapAnalysis; - matchHistory: ScoutingMatchHistoryEntry[]; - recommendations: ScoutingRecommendations; -}; - -const HALF_LIFE_DAYS = 90; -const DECAY_CONSTANT = Math.LN2 / HALF_LIFE_DAYS; - -function calculateWeight(matchDate: Date): number { - const daysAgo = (Date.now() - matchDate.getTime()) / (1000 * 60 * 60 * 24); - return Math.exp(-DECAY_CONSTANT * daysAgo); -} - -function weightedRate(items: { won: boolean; weight: number }[]): number { - if (items.length === 0) return 0; - const totalWeight = items.reduce((sum, i) => sum + i.weight, 0); - if (totalWeight === 0) return 0; - const winWeight = items - .filter((i) => i.won) - .reduce((sum, i) => sum + i.weight, 0); - return (winWeight / totalWeight) * 100; -} - -type TeamAppearanceRow = { - team: string; - team_full_name: string; - match_count: bigint; - win_count: bigint; -}; - -async function getScoutingTeamsFn(): Promise { - const rows = await prisma.$queryRaw` - WITH appearances AS ( - SELECT team1 AS team, "team1FullName" AS team_full_name, id, - CASE WHEN winner = team1 THEN 1 ELSE 0 END AS won - FROM "ScoutingMatch" - UNION ALL - SELECT team2 AS team, "team2FullName" AS team_full_name, id, - CASE WHEN winner = team2 THEN 1 ELSE 0 END AS won - FROM "ScoutingMatch" - ) - SELECT - team, - team_full_name, - COUNT(*)::bigint AS match_count, - SUM(won)::bigint AS win_count - FROM appearances - GROUP BY team, team_full_name - ORDER BY match_count DESC - `; - - return rows.map((row) => ({ - abbreviation: row.team, - fullName: row.team_full_name, - matchCount: Number(row.match_count), - winCount: Number(row.win_count), - })); -} - -export const getScoutingTeams = cache(getScoutingTeamsFn); - -const opponentMatchInclude = { - maps: { include: { heroBans: true } }, - tournament: { select: { title: true } }, -} satisfies Prisma.ScoutingMatchInclude; - -export type OpponentMatchRow = Prisma.ScoutingMatchGetPayload<{ - include: typeof opponentMatchInclude; -}>; - -async function getOpponentMatchDataFn( - teamAbbr: string -): Promise { - return prisma.scoutingMatch.findMany({ - where: { OR: [{ team1: teamAbbr }, { team2: teamAbbr }] }, - include: opponentMatchInclude, - orderBy: { matchDate: "asc" }, - }); -} - -export const getOpponentMatchData = cache(getOpponentMatchDataFn); - -async function getScoutingTeamProfileFn( - teamAbbr: string -): Promise { - const matchesAsc = await getOpponentMatchData(teamAbbr); - - if (matchesAsc.length === 0) return null; - - const matches = [...matchesAsc].reverse(); - - const firstMatch = matches[0]; - const fullName = - firstMatch.team1 === teamAbbr - ? firstMatch.team1FullName - : firstMatch.team2FullName; - - type ProcessedMatch = { - isTeam1: boolean; - won: boolean; - weight: number; - date: Date; - opponent: string; - opponentFullName: string; - teamScore: number | null; - opponentScore: number | null; - tournament: string; - maps: typeof firstMatch.maps; - }; - - const processed: ProcessedMatch[] = matches.map((m) => { - const isTeam1 = m.team1 === teamAbbr; - const won = m.winner === teamAbbr; - return { - isTeam1, - won, - weight: calculateWeight(m.matchDate), - date: m.matchDate, - opponent: isTeam1 ? m.team2 : m.team1, - opponentFullName: isTeam1 ? m.team2FullName : m.team1FullName, - teamScore: isTeam1 ? m.team1Score : m.team2Score, - opponentScore: isTeam1 ? m.team2Score : m.team1Score, - tournament: m.tournament.title, - maps: m.maps, - }; - }); - - const wins = processed.filter((m) => m.won).length; - const losses = processed.length - wins; - const overview: ScoutingTeamOverview = { - totalMatches: processed.length, - wins, - losses, - winRate: processed.length > 0 ? (wins / processed.length) * 100 : 0, - weightedWinRate: weightedRate(processed), - recentForm: processed.slice(0, 10).map((m) => (m.won ? "win" : "loss")), - }; - - const bansByTeamMap = new Map(); - const bansAgainstMap = new Map(); - - for (const match of processed) { - const teamSide = match.isTeam1 ? "team1" : "team2"; - - for (const map of match.maps) { - for (const ban of map.heroBans) { - const target = ban.team === teamSide ? bansByTeamMap : bansAgainstMap; - const existing = target.get(ban.hero) ?? { raw: 0, weighted: 0 }; - existing.raw += 1; - existing.weighted += match.weight; - target.set(ban.hero, existing); - } - } - } - - function toBanEntries( - map: Map - ): HeroBanEntry[] { - return Array.from(map.entries()) - .map(([hero, data]) => ({ - hero, - rawCount: data.raw, - weightedCount: Math.round(data.weighted * 100) / 100, - })) - .sort((a, b) => b.weightedCount - a.weightedCount); - } - - const heroBans: ScoutingHeroBans = { - bansByTeam: toBanEntries(bansByTeamMap), - bansAgainstTeam: toBanEntries(bansAgainstMap), - }; - - type MapAccum = { played: { won: boolean; weight: number }[] }; - const byMapName = new Map(); - const byMapTypeMap = new Map(); - - for (const match of processed) { - const teamSide = match.isTeam1 ? "team1" : "team2"; - for (const map of match.maps) { - const mapWon = map.winner === teamSide; - const entry = { won: mapWon, weight: match.weight }; - - const nameAccum = byMapName.get(map.mapName) ?? { played: [] }; - nameAccum.played.push(entry); - byMapName.set(map.mapName, nameAccum); - - const typeAccum = byMapTypeMap.get(map.mapType) ?? { played: [] }; - typeAccum.played.push(entry); - byMapTypeMap.set(map.mapType, typeAccum); - } - } - - function toMapPerformance( - accum: MapAccum - ): Pick< - MapPerformanceEntry, - "played" | "won" | "winRate" | "weightedWinRate" - > { - const won = accum.played.filter((p) => p.won).length; - return { - played: accum.played.length, - won, - winRate: accum.played.length > 0 ? (won / accum.played.length) * 100 : 0, - weightedWinRate: weightedRate(accum.played), - }; - } - - const mapAnalysis: ScoutingMapAnalysis = { - byMap: Array.from(byMapName.entries()) - .map(([name, accum]) => ({ name, ...toMapPerformance(accum) })) - .sort((a, b) => b.played - a.played), - byMapType: Array.from(byMapTypeMap.entries()) - .map(([mapType, accum]) => ({ - name: mapType, - mapType, - ...toMapPerformance(accum), - })) - .sort((a, b) => b.played - a.played), - }; - - const matchHistory: ScoutingMatchHistoryEntry[] = processed.map((m) => ({ - date: m.date, - opponent: m.opponent, - opponentFullName: m.opponentFullName, - teamScore: m.teamScore, - opponentScore: m.opponentScore, - result: m.won ? "win" : "loss", - tournament: m.tournament, - })); - - const suggestedBans: ScoutingRecommendation[] = heroBans.bansAgainstTeam - .slice(0, 5) - .map((ban) => ({ - name: ban.hero, - reason: `Banned against ${teamAbbr} ${ban.rawCount} times (opponents target this hero)`, - weightedWinRate: ban.weightedCount, - sampleSize: ban.rawCount, - })); - - const MIN_MAP_SAMPLE = 3; - const mapsWithEnoughData = mapAnalysis.byMap.filter( - (m) => m.played >= MIN_MAP_SAMPLE - ); - - const suggestedMapPicks: ScoutingRecommendation[] = [...mapsWithEnoughData] - .sort((a, b) => a.weightedWinRate - b.weightedWinRate) - .slice(0, 3) - .map((m) => ({ - name: m.name, - reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, - weightedWinRate: m.weightedWinRate, - sampleSize: m.played, - })); - - const suggestedMapAvoids: ScoutingRecommendation[] = [...mapsWithEnoughData] - .sort((a, b) => b.weightedWinRate - a.weightedWinRate) - .slice(0, 3) - .map((m) => ({ - name: m.name, - reason: `${teamAbbr} has a ${m.weightedWinRate.toFixed(0)}% weighted WR across ${m.played} maps`, - weightedWinRate: m.weightedWinRate, - sampleSize: m.played, - })); - - return { - team: { abbreviation: teamAbbr, fullName }, - overview, - heroBans, - mapAnalysis, - matchHistory, - recommendations: { suggestedBans, suggestedMapPicks, suggestedMapAvoids }, - }; -} - -export const getScoutingTeamProfile = cache(getScoutingTeamProfileFn); diff --git a/src/data/scrim-ability-timing-dto.ts b/src/data/scrim-ability-timing-dto.ts deleted file mode 100644 index 71b1ce4e5..000000000 --- a/src/data/scrim-ability-timing-dto.ts +++ /dev/null @@ -1,680 +0,0 @@ -import "server-only"; - -import { getTeamRoster } from "@/data/team-shared-data"; -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import prisma from "@/lib/prisma"; -import { - groupEventsIntoFights, - mercyRezToKillEvent, - removeDuplicateRows, - type Fight, -} from "@/lib/utils"; -import type { AbilityImpact } from "@/types/heroes"; -import { allHeroes } from "@/types/heroes"; -import { cache } from "react"; - -export type FightPhase = "pre-fight" | "early" | "mid" | "late" | "cleanup"; - -export type AbilityPhaseStats = { - fights: number; - wins: number; - losses: number; - winrate: number; -}; - -export type AbilityTimingRow = { - heroName: string; - abilityName: string; - abilitySlot: 1 | 2; - impactRating: "high" | "critical"; - phases: Record; - overallWinrate: number; - totalFights: number; -}; - -export type AbilityTimingOutlier = { - heroName: string; - abilityName: string; - phase: FightPhase; - phaseWinrate: number; - overallWinrate: number; - deviation: number; - bestPhase: FightPhase; - bestPhaseWinrate: number; - type: "positive" | "negative"; -}; - -export type AbilityTimingAnalysis = { - rows: AbilityTimingRow[]; - outliers: AbilityTimingOutlier[]; -}; - -type AbilityDef = { - name: string; - impact: AbilityImpact; -}; - -const heroAbilityLookup = new Map< - string, - { ability1: AbilityDef; ability2: AbilityDef } ->(); -for (const hero of allHeroes) { - heroAbilityLookup.set(hero.name, { - ability1: { name: hero.ability1.name, impact: hero.ability1.impact }, - ability2: { name: hero.ability2.name, impact: hero.ability2.impact }, - }); -} - -function isHighImpact(impact: AbilityImpact): boolean { - return impact === "high" || impact === "critical"; -} - -const PRE_FIGHT_BUFFER = 5; -const CLEANUP_BUFFER = 2; - -function classifyPhase(abilityTime: number, fight: Fight): FightPhase | null { - const fightStart = fight.start; - const fightEnd = fight.end; - const duration = fightEnd - fightStart; - - if ( - abilityTime < fightStart - PRE_FIGHT_BUFFER || - abilityTime > fightEnd + CLEANUP_BUFFER - ) { - return null; - } - - if (abilityTime < fightStart) { - return "pre-fight"; - } - - if (abilityTime > fightEnd) { - return "cleanup"; - } - - if (duration < 4) { - return "mid"; - } - - const elapsed = abilityTime - fightStart; - const progress = elapsed / duration; - - if (progress <= 0.25) return "early"; - if (progress <= 0.75) return "mid"; - return "late"; -} - -function assignToFight( - abilityTime: number, - fights: Fight[] -): { fight: Fight; phase: FightPhase } | null { - let bestMatch: { fight: Fight; phase: FightPhase; distance: number } | null = - null; - - for (const fight of fights) { - const phase = classifyPhase(abilityTime, fight); - if (phase === null) continue; - - const fightCenter = (fight.start + fight.end) / 2; - const distance = Math.abs(abilityTime - fightCenter); - - if (!bestMatch || distance < bestMatch.distance) { - bestMatch = { fight, phase, distance }; - } - } - - return bestMatch ? { fight: bestMatch.fight, phase: bestMatch.phase } : null; -} - -function determineFightOutcome( - fight: Fight, - ourTeamName: string -): "win" | "loss" { - let ourKills = 0; - let enemyKills = 0; - - for (const event of fight.kills) { - if (event.event_type === ("mercy_rez" as string)) { - if (event.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) ourKills++; - else enemyKills++; - } - } - - return ourKills > enemyKills ? "win" : "loss"; -} - -type AbilityEvent = { - match_time: number; - player_team: string; - player_hero: string; - MapDataId: number | null; -}; - -function emptyPhaseStats(): AbilityPhaseStats { - return { fights: 0, wins: 0, losses: 0, winrate: 0 }; -} - -function emptyPhases(): Record { - return { - "pre-fight": emptyPhaseStats(), - early: emptyPhaseStats(), - mid: emptyPhaseStats(), - late: emptyPhaseStats(), - cleanup: emptyPhaseStats(), - }; -} - -const PHASE_ORDER: FightPhase[] = [ - "pre-fight", - "early", - "mid", - "late", - "cleanup", -]; - -const MIN_FIGHTS_FOR_DISPLAY = 3; -const OUTLIER_THRESHOLD_PP = 15; - -function processAbilityTimingAnalysis( - fights: Fight[], - ability1Events: AbilityEvent[], - ability2Events: AbilityEvent[], - ourTeamName: string -): AbilityTimingAnalysis { - if (fights.length === 0) { - return { rows: [], outliers: [] }; - } - - const fightOutcomes = new Map(); - for (const fight of fights) { - fightOutcomes.set(fight, determineFightOutcome(fight, ourTeamName)); - } - - type AccumKey = string; - const accum = new Map< - AccumKey, - { - heroName: string; - abilityName: string; - abilitySlot: 1 | 2; - impactRating: "high" | "critical"; - fightPhaseUsage: Map>; - } - >(); - - function processEvents(events: AbilityEvent[], slot: 1 | 2) { - for (const event of events) { - if (event.player_team !== ourTeamName) continue; - - const heroDef = heroAbilityLookup.get(event.player_hero); - if (!heroDef) continue; - - const abilityDef = slot === 1 ? heroDef.ability1 : heroDef.ability2; - if (!isHighImpact(abilityDef.impact)) continue; - - const match = assignToFight(event.match_time, fights); - if (!match) continue; - - const key = `${event.player_hero}|${slot}`; - if (!accum.has(key)) { - accum.set(key, { - heroName: event.player_hero, - abilityName: abilityDef.name, - abilitySlot: slot, - impactRating: abilityDef.impact as "high" | "critical", - fightPhaseUsage: new Map(), - }); - } - - const entry = accum.get(key)!; - if (!entry.fightPhaseUsage.has(match.fight)) { - entry.fightPhaseUsage.set(match.fight, new Set()); - } - entry.fightPhaseUsage.get(match.fight)!.add(match.phase); - } - } - - processEvents(ability1Events, 1); - processEvents(ability2Events, 2); - - const rows: AbilityTimingRow[] = []; - - for (const entry of accum.values()) { - const phases = emptyPhases(); - let totalWins = 0; - let totalFights = 0; - - for (const [fight, phasesUsed] of entry.fightPhaseUsage) { - const outcome = fightOutcomes.get(fight)!; - const won = outcome === "win"; - totalFights++; - if (won) totalWins++; - - for (const phase of phasesUsed) { - phases[phase].fights++; - if (won) phases[phase].wins++; - else phases[phase].losses++; - } - } - - for (const phase of PHASE_ORDER) { - const s = phases[phase]; - s.winrate = s.fights > 0 ? (s.wins / s.fights) * 100 : 0; - } - - if (totalFights === 0) continue; - - rows.push({ - heroName: entry.heroName, - abilityName: entry.abilityName, - abilitySlot: entry.abilitySlot, - impactRating: entry.impactRating, - phases, - overallWinrate: (totalWins / totalFights) * 100, - totalFights, - }); - } - - rows.sort((a, b) => { - if (a.impactRating !== b.impactRating) { - return a.impactRating === "critical" ? -1 : 1; - } - return b.totalFights - a.totalFights; - }); - - const outliers: AbilityTimingOutlier[] = []; - - for (const row of rows) { - let bestPhase: FightPhase = "mid"; - let bestWinrate = -1; - - for (const phase of PHASE_ORDER) { - const s = row.phases[phase]; - if (s.fights >= MIN_FIGHTS_FOR_DISPLAY && s.winrate > bestWinrate) { - bestWinrate = s.winrate; - bestPhase = phase; - } - } - - for (const phase of PHASE_ORDER) { - const s = row.phases[phase]; - if (s.fights < MIN_FIGHTS_FOR_DISPLAY) continue; - - const deviation = s.winrate - row.overallWinrate; - if (Math.abs(deviation) < OUTLIER_THRESHOLD_PP) continue; - - outliers.push({ - heroName: row.heroName, - abilityName: row.abilityName, - phase, - phaseWinrate: s.winrate, - overallWinrate: row.overallWinrate, - deviation, - bestPhase, - bestPhaseWinrate: bestWinrate, - type: deviation < 0 ? "negative" : "positive", - }); - } - } - - outliers.sort((a, b) => Math.abs(b.deviation) - Math.abs(a.deviation)); - const topOutliers = outliers.slice(0, 3); - - return { rows, outliers: topOutliers }; -} - -async function getScrimAbilityTimingFn( - scrimId: number, - teamId: number -): Promise { - const maps = await prisma.map.findMany({ - where: { scrimId }, - orderBy: { id: "asc" }, - select: { id: true, mapData: { select: { id: true } } }, - }); - - if (maps.length === 0) return { rows: [], outliers: [] }; - - const mapIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); - - const [teamRosterArr, allKills, allRezzes, allAbility1, allAbility2] = - await Promise.all([ - getTeamRoster(teamId), - prisma.kill.findMany({ - where: { MapDataId: { in: mapIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.mercyRez.findMany({ - where: { MapDataId: { in: mapIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.ability1Used.findMany({ - where: { MapDataId: { in: mapIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: { in: mapIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - const teamRoster = new Set(teamRosterArr); - - const dedupedKills = removeDuplicateRows(allKills); - const killEvents = [ - ...dedupedKills, - ...allRezzes.map(mercyRezToKillEvent), - ].sort((a, b) => a.match_time - b.match_time); - - const fights = groupEventsIntoFights(killEvents); - - if (fights.length === 0) return { rows: [], outliers: [] }; - - let ourTeamName = ""; - for (const kill of dedupedKills) { - if (teamRoster.has(kill.attacker_name)) { - ourTeamName = kill.attacker_team; - break; - } - if (kill.victim_name && teamRoster.has(kill.victim_name)) { - ourTeamName = kill.victim_team; - break; - } - } - - if (!ourTeamName) return { rows: [], outliers: [] }; - - return processAbilityTimingAnalysis( - fights, - allAbility1, - allAbility2, - ourTeamName - ); -} - -export const getScrimAbilityTiming = cache(getScrimAbilityTimingFn); - -// --------------------------------------------------------------------------- -// Per-fight timeline (for AI analyst tool) -// --------------------------------------------------------------------------- - -export type FightAbilityEvent = { - time: number; - hero: string; - ability: string; - abilitySlot: 1 | 2; - team: "ours" | "enemy"; - phase: FightPhase; -}; - -export type FightKillEvent = { - time: number; - attacker: string; - attackerHero: string; - victim: string; - victimHero: string; - attackerSide: "ours" | "enemy"; -}; - -export type FightTimeline = { - fightNumber: number; - startTime: number; - endTime: number; - duration: number; - outcome: "win" | "loss"; - kills: FightKillEvent[]; - abilityUses: FightAbilityEvent[]; -}; - -export type ScrimFightTimelines = { - fights: FightTimeline[]; - ourTeamName: string; -}; - -async function getScrimFightTimelinesFn( - scrimId: number, - teamId: number -): Promise { - const maps = await prisma.map.findMany({ - where: { scrimId }, - orderBy: { id: "asc" }, - select: { id: true, mapData: { select: { id: true } } }, - }); - - if (maps.length === 0) return { fights: [], ourTeamName: "" }; - - const mapIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); - - const [teamRosterArr, allKills, allRezzes, allAbility1, allAbility2] = - await Promise.all([ - getTeamRoster(teamId), - prisma.kill.findMany({ - where: { MapDataId: { in: mapIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.mercyRez.findMany({ - where: { MapDataId: { in: mapIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.ability1Used.findMany({ - where: { MapDataId: { in: mapIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - player_name: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: { in: mapIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - player_name: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - const teamRoster = new Set(teamRosterArr); - - const dedupedKills = removeDuplicateRows(allKills); - const killEvents = [ - ...dedupedKills, - ...allRezzes.map(mercyRezToKillEvent), - ].sort((a, b) => a.match_time - b.match_time); - - const fights = groupEventsIntoFights(killEvents); - - if (fights.length === 0) return { fights: [], ourTeamName: "" }; - - let ourTeamName = ""; - for (const kill of dedupedKills) { - if (teamRoster.has(kill.attacker_name)) { - ourTeamName = kill.attacker_team; - break; - } - if (kill.victim_name && teamRoster.has(kill.victim_name)) { - ourTeamName = kill.victim_team; - break; - } - } - - if (!ourTeamName) return { fights: [], ourTeamName: "" }; - - const timelines: FightTimeline[] = fights.map((fight, idx) => { - const outcome = determineFightOutcome(fight, ourTeamName); - - const kills: FightKillEvent[] = fight.kills - .filter((k) => k.event_type === "kill") - .map((k) => ({ - time: Math.round(k.match_time * 10) / 10, - attacker: k.attacker_name, - attackerHero: k.attacker_hero, - victim: k.victim_name, - victimHero: k.victim_hero, - attackerSide: - k.attacker_team === ourTeamName - ? ("ours" as const) - : ("enemy" as const), - })); - - // Find ability uses within fight window - const windowStart = fight.start - PRE_FIGHT_BUFFER; - const windowEnd = fight.end + CLEANUP_BUFFER; - - function mapAbilityEvents( - events: { - match_time: number; - player_team: string; - player_hero: string; - player_name: string; - MapDataId: number | null; - }[], - slot: 1 | 2 - ): FightAbilityEvent[] { - return events - .filter((e) => e.match_time >= windowStart && e.match_time <= windowEnd) - .map((e) => { - const heroDef = heroAbilityLookup.get(e.player_hero); - const abilityDef = slot === 1 ? heroDef?.ability1 : heroDef?.ability2; - const phase = classifyPhase(e.match_time, fight); - - return { - time: Math.round(e.match_time * 10) / 10, - hero: e.player_hero, - ability: abilityDef?.name ?? `ability${slot}`, - abilitySlot: slot, - team: - e.player_team === ourTeamName - ? ("ours" as const) - : ("enemy" as const), - phase: phase ?? ("mid" as FightPhase), - }; - }); - } - - const abilityUses = [ - ...mapAbilityEvents(allAbility1, 1), - ...mapAbilityEvents(allAbility2, 2), - ].sort((a, b) => a.time - b.time); - - return { - fightNumber: idx + 1, - startTime: Math.round(fight.start * 10) / 10, - endTime: Math.round(fight.end * 10) / 10, - duration: Math.round((fight.end - fight.start) * 10) / 10, - outcome, - kills, - abilityUses, - }; - }); - - return { fights: timelines, ourTeamName }; -} - -export const getScrimFightTimelines = cache(getScrimFightTimelinesFn); - -export type MapAbilityTimingAnalysis = { - team1: AbilityTimingAnalysis; - team2: AbilityTimingAnalysis; -}; - -async function getMapAbilityTimingFn( - mapId: number, - team1Name: string, - team2Name: string -): Promise { - const empty: MapAbilityTimingAnalysis = { - team1: { rows: [], outliers: [] }, - team2: { rows: [], outliers: [] }, - }; - - const mapDataId = await resolveMapDataId(mapId); - - const [allKills, allRezzes, allAbility1, allAbility2] = await Promise.all([ - prisma.kill.findMany({ - where: { MapDataId: mapDataId }, - orderBy: { match_time: "asc" }, - }), - prisma.mercyRez.findMany({ - where: { MapDataId: mapDataId }, - orderBy: { match_time: "asc" }, - }), - prisma.ability1Used.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: mapDataId }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - if (allAbility1.length === 0 && allAbility2.length === 0) { - return empty; - } - - const dedupedKills = removeDuplicateRows(allKills); - const killEvents = [ - ...dedupedKills, - ...allRezzes.map(mercyRezToKillEvent), - ].sort((a, b) => a.match_time - b.match_time); - - const fights = groupEventsIntoFights(killEvents); - - if (fights.length === 0) return empty; - - // Note: fight outcomes are relative to the team passed as "ourTeamName", - // so team2's analysis naturally inverts win/loss. - const team1Analysis = processAbilityTimingAnalysis( - fights, - allAbility1, - allAbility2, - team1Name - ); - const team2Analysis = processAbilityTimingAnalysis( - fights, - allAbility1, - allAbility2, - team2Name - ); - - return { team1: team1Analysis, team2: team2Analysis }; -} - -export const getMapAbilityTiming = cache(getMapAbilityTimingFn); diff --git a/src/data/scrim-dto.tsx b/src/data/scrim-dto.tsx deleted file mode 100644 index ed0284563..000000000 --- a/src/data/scrim-dto.tsx +++ /dev/null @@ -1,373 +0,0 @@ -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import prisma from "@/lib/prisma"; -import { removeDuplicateRows } from "@/lib/utils"; -import { calculateWinner } from "@/lib/winrate"; -import { type HeroName, heroPriority, heroRoleMapping } from "@/types/heroes"; -import { - type MatchStart, - type PlayerStat, - Prisma, - type RoundEnd, -} from "@prisma/client"; -import { cache } from "react"; -import { - buildCapturesMaps, - buildMatchStartMap, - buildProgressMaps, -} from "./team-shared-core"; - -async function getScrimFn(id: number) { - return await prisma.scrim.findFirst({ where: { id } }); -} - -export const getScrim = cache(getScrimFn); - -async function getUserViewableScrimsFn(id: string) { - return await prisma.scrim.findMany({ - where: { OR: [{ creatorId: id }, { Team: { users: { some: { id } } } }] }, - }); -} - -/** - * Returns the scrims that a user is allowed to view. - * - * @param id The ID of the user. - * @returns The scrims that the user is allowed to view. - */ -export const getUserViewableScrims = cache(getUserViewableScrimsFn); - -/** - * This query performs the following operations: - * 1. It first creates a subquery that selects the maximum match time (i.e., the final round time) - * for a given map (`MapDataId`). This is achieved by grouping the `PlayerStat` records - * by `MapDataId` and calculating the maximum `match_time` for each group. - * 2. The main query then joins the results of this subquery with the original `PlayerStat` table. - * This join is based on matching the `MapDataId` and the `match_time` with the calculated maximum - * match time from the subquery. - * 3. Finally, the query implicitly filters the results to include only those records that match the given `MapDataId`, - * effectively returning statistics for players at the final match time of the specified scrim. - */ -async function getFinalRoundStatsFn(id: number) { - const mapDataId = await resolveMapDataId(id); - return removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time - FROM - "PlayerStat" - WHERE - "MapDataId" = ${mapDataId} - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time - WHERE - ps."MapDataId" = ${mapDataId}` - ) - .sort((a, b) => a.player_name.localeCompare(b.player_name)) - .sort( - (a, b) => - heroPriority[heroRoleMapping[a.player_hero as HeroName]] - - heroPriority[heroRoleMapping[b.player_hero as HeroName]] - ) - .sort((a, b) => a.player_team.localeCompare(b.player_team)); -} - -/** - * Returns the statistics for the final round of a map. - * This function is cached for performance. - * - * @param id The ID of the map. - * @returns The statistics for the final round of the specified map. - */ -export const getFinalRoundStats = cache(getFinalRoundStatsFn); - -async function getFinalRoundStatsForPlayerFn(id: number, playerName: string) { - const mapDataId = await resolveMapDataId(id); - return removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time - FROM - "PlayerStat" - WHERE - "MapDataId" = ${mapDataId} - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time - WHERE - ps."MapDataId" = ${mapDataId} - AND ps."player_name" = ${playerName}` - ); -} - -/** - * Returns the statistics for the final round of a map for a specific player. - * This function is cached for performance. - * - * @param id The ID of the map. - * @param playerName The name of the player. - * @returns The statistics for the final round of the specified map for the specified player. - */ -export const getPlayerFinalStats = cache(getFinalRoundStatsForPlayerFn); - -async function getAllStatsForPlayerFn(scrimIds: number[], name: string) { - if (scrimIds.length === 0) return []; - - const scrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: { select: { mapData: { select: { id: true } } } } }, - }); - - const mapDataIdSet = new Set(); - scrims.forEach((scrim) => { - scrim.maps.forEach((map) => { - map.mapData.forEach((md) => mapDataIdSet.add(md.id)); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "MapDataId" IN (${Prisma.join(mapDataIdArray)}) - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."MapDataId" IN (${Prisma.join(mapDataIdArray)}) - AND ps."player_name" ILIKE ${name}` - ); -} - -/** - * Returns all of the statistics for a specific player. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the player participated in. - * @param playerName The name of the player. - * @returns The statistics for the specified player. - */ -export const getAllStatsForPlayer = cache(getAllStatsForPlayerFn); - -async function getAllKillsForPlayerFn(scrimIds: number[], name: string) { - const scrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: { select: { mapData: { select: { id: true } } } } }, - }); - - const mapDataIdSet = new Set(); - scrims.forEach((scrim) => { - scrim.maps.forEach((map) => { - map.mapData.forEach((md) => mapDataIdSet.add(md.id)); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return await prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIdArray }, - attacker_name: { equals: name, mode: "insensitive" }, - }, - }); -} - -/** - * Returns all of the kills for a specific player. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the player participated in. - * @param playerName The name of the player. - * @returns The kills for the specified player. - */ -export const getAllKillsForPlayer = cache(getAllKillsForPlayerFn); - -async function getAllDeathsForPlayerFn(scrimIds: number[], name: string) { - const scrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { maps: { select: { mapData: { select: { id: true } } } } }, - }); - - const mapDataIdSet = new Set(); - scrims.forEach((scrim) => { - scrim.maps.forEach((map) => { - map.mapData.forEach((md) => mapDataIdSet.add(md.id)); - }); - }); - - const mapDataIdArray = Array.from(mapDataIdSet); - - return await prisma.kill.findMany({ - where: { - MapDataId: { in: mapDataIdArray }, - victim_name: { equals: name, mode: "insensitive" }, - }, - }); -} - -/** - * Returns all of the deaths for a specific player. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the player participated in. - * @param playerName The name of the player. - * @returns The deaths for the specified player. - */ -export const getAllDeathsForPlayer = cache(getAllDeathsForPlayerFn); - -export type Winrate = { map: string; wins: number; date: Date }[]; - -async function getAllMapWinratesForPlayerFn(scrimIds: number[], name: string) { - const scrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, - select: { - maps: { select: { mapData: { select: { id: true } } } }, - date: true, - id: true, - }, - }); - - const mapDataIdSet = new Set(); - const mapIdToDateMap = new Map(); - scrims.forEach((scrim) => { - scrim.maps.forEach((map) => { - map.mapData.forEach((md) => { - mapDataIdSet.add(md.id); - mapIdToDateMap.set(md.id, scrim.date); - }); - }); - }); - const mapDataIdArray = Array.from(mapDataIdSet); - - const [ - matchStarts, - allFinalRounds, - captures, - payloadProgresses, - pointProgresses, - playerStats, - ] = await Promise.all([ - prisma.matchStart.findMany({ - where: { MapDataId: { in: mapDataIdArray } }, - }), - prisma.roundEnd.findMany({ - where: { MapDataId: { in: mapDataIdArray } }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: mapDataIdArray } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: mapDataIdArray } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: mapDataIdArray } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.playerStat.findMany({ - where: { - player_name: { equals: name, mode: "insensitive" }, - MapDataId: { in: mapDataIdArray }, - }, - }), - ]); - - const finalRounds = allFinalRounds.reduce( - (acc, round) => { - if ( - !acc[round.MapDataId!] || - acc[round.MapDataId!].match_time < round.match_time - ) { - acc[round.MapDataId!] = round; - } - return acc; - }, - {} as Record - ); - - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - const wins: { map: string; wins: number; date: Date }[] = []; - - for (const mapId of mapDataIdArray) { - const playerStat = playerStats.find((stat) => stat.MapDataId === mapId); - if (!playerStat) continue; - - const matchDetails: MatchStart = - matchStarts.find((match) => match.MapDataId === mapId) ?? - // oxlint-disable-next-line @typescript-eslint/consistent-type-assertions - ({} as MatchStart); - - const winner = calculateWinner({ - matchDetails, - finalRound: finalRounds[mapId], - team1Captures: team1CapturesMap.get(mapId) ?? [], - team2Captures: team2CapturesMap.get(mapId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapId) ?? [], - }); - - const playerTeam = playerStat?.player_team; - - wins.push({ - map: matchDetails.map_name, - wins: winner === playerTeam ? 1 : 0, - date: mapIdToDateMap.get(mapId) ?? new Date(), - }); - } - - return wins; -} - -/** - * Returns the winrates for a specific player on each map. - * This function is cached for performance. - * - * @param scrimIds The IDs of the scrims the player participated in. - * @param playerName The name of the player. - * @returns The winrates for the specified player on each map. - */ -export const getAllMapWinratesForPlayer = cache(getAllMapWinratesForPlayerFn); diff --git a/src/data/scrim-opponent-dto.ts b/src/data/scrim-opponent-dto.ts deleted file mode 100644 index 5d15850d9..000000000 --- a/src/data/scrim-opponent-dto.ts +++ /dev/null @@ -1,588 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import { heroRoleMapping, type HeroName } from "@/types/heroes"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import type { - MapType, - MatchStart, - ObjectiveCaptured, - PayloadProgress, - PointProgress, - RoundEnd, -} from "@prisma/client"; -import { cache } from "react"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getTeamRoster } from "./team-shared-data"; - -type HeroRole = "Tank" | "Damage" | "Support"; - -export type ScrimMapResult = { - mapName: string; - mapType: MapType; - scrimDate: Date; - opponentWon: boolean; - source: "scrim"; -}; - -export type ScrimHeroBan = { - mapName: string; - mapType: MapType; - scrimDate: Date; - opponentWon: boolean; - opponentBans: string[]; - source: "scrim"; -}; - -export type ScrimPlayerStat = { - playerName: string; - hero: string; - role: HeroRole; - timePlayed: number; - eliminations: number; - deaths: number; - source: "scrim"; -}; - -type MapDataRow = { - id: number; - mapName: string; - mapType: MapType | null; - scrimDate: Date; -}; - -type ScrimPlayerStatRow = { - player_name: string; - player_team: string; - MapDataId: number | null; -}; - -/** - * Fetches all tagged scrims and collects the raw data needed to resolve - * opponent-side analytics. Returns structured collections ready for - * per-MapData resolution. - */ -async function fetchTaggedScrimDataFn( - userTeamId: number, - opponentAbbr: string -): Promise<{ - mapDataRows: MapDataRow[]; - allPlayerStats: ScrimPlayerStatRow[]; - teamRosterSet: Set; - matchStartsByMapDataId: Map; - finalRoundsByMapDataId: Map; - capturesByTeam: Map< - number, - { team1: ObjectiveCaptured[]; team2: ObjectiveCaptured[] } - >; - payloadProgressByTeam: Map< - number, - { team1: PayloadProgress[]; team2: PayloadProgress[] } - >; - pointProgressByTeam: Map< - number, - { team1: PointProgress[]; team2: PointProgress[] } - >; - heroBansByMapDataId: Map; - fullPlayerStatsByMapDataId: Map< - number, - { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - eliminations: number; - deaths: number; - }[] - >; -}> { - const teamRoster = await getTeamRoster(userTeamId); - const teamRosterSet = new Set(teamRoster); - - const scrims = await prisma.scrim.findMany({ - where: { teamId: userTeamId, opponentTeamAbbr: opponentAbbr }, - select: { - date: true, - maps: { - select: { - id: true, - name: true, - mapData: { select: { id: true } }, - }, - }, - }, - }); - - if (scrims.length === 0) { - return { - mapDataRows: [], - allPlayerStats: [], - teamRosterSet, - matchStartsByMapDataId: new Map(), - finalRoundsByMapDataId: new Map(), - capturesByTeam: new Map(), - payloadProgressByTeam: new Map(), - pointProgressByTeam: new Map(), - heroBansByMapDataId: new Map(), - fullPlayerStatsByMapDataId: new Map(), - }; - } - - const mapDataRows: MapDataRow[] = []; - const allMapDataIds: number[] = []; - - for (const scrim of scrims) { - for (const map of scrim.maps) { - for (const mapData of map.mapData) { - allMapDataIds.push(mapData.id); - mapDataRows.push({ - id: mapData.id, - mapName: map.name, - mapType: - mapNameToMapTypeMapping[ - map.name as keyof typeof mapNameToMapTypeMapping - ] ?? null, - scrimDate: scrim.date, - }); - } - } - } - - if (allMapDataIds.length === 0) { - return { - mapDataRows: [], - allPlayerStats: [], - teamRosterSet, - matchStartsByMapDataId: new Map(), - finalRoundsByMapDataId: new Map(), - capturesByTeam: new Map(), - payloadProgressByTeam: new Map(), - pointProgressByTeam: new Map(), - heroBansByMapDataId: new Map(), - fullPlayerStatsByMapDataId: new Map(), - }; - } - - const [ - allPlayerStatsFull, - matchStarts, - roundEnds, - captures, - payloadProgressRows, - pointProgressRows, - heroBans, - ] = await Promise.all([ - prisma.playerStat.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - eliminations: true, - deaths: true, - MapDataId: true, - }, - }), - prisma.matchStart.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - }), - prisma.roundEnd.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - orderBy: { round_number: "desc" }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.heroBan.findMany({ - where: { MapDataId: { in: allMapDataIds } }, - select: { MapDataId: true, team: true, hero: true }, - }), - ]); - - const allPlayerStats: ScrimPlayerStatRow[] = allPlayerStatsFull.map((s) => ({ - player_name: s.player_name, - player_team: s.player_team, - MapDataId: s.MapDataId, - })); - - const matchStartsByMapDataId = new Map(); - for (const ms of matchStarts) { - if (ms.MapDataId) matchStartsByMapDataId.set(ms.MapDataId, ms); - } - - const finalRoundsByMapDataId = new Map(); - for (const round of roundEnds) { - if (!round.MapDataId) continue; - const existing = finalRoundsByMapDataId.get(round.MapDataId); - if (!existing || round.round_number > existing.round_number) { - finalRoundsByMapDataId.set(round.MapDataId, round); - } - } - - const capturesByTeam = new Map< - number, - { team1: ObjectiveCaptured[]; team2: ObjectiveCaptured[] } - >(); - for (const capture of captures) { - if (!capture.MapDataId) continue; - const ms = matchStartsByMapDataId.get(capture.MapDataId); - if (!ms) continue; - let entry = capturesByTeam.get(capture.MapDataId); - if (!entry) { - entry = { team1: [], team2: [] }; - capturesByTeam.set(capture.MapDataId, entry); - } - if (capture.capturing_team === ms.team_1_name) { - entry.team1.push(capture); - } else if (capture.capturing_team === ms.team_2_name) { - entry.team2.push(capture); - } - } - - const payloadProgressByTeam = new Map< - number, - { team1: PayloadProgress[]; team2: PayloadProgress[] } - >(); - for (const progressRow of payloadProgressRows) { - if (!progressRow.MapDataId) continue; - const ms = matchStartsByMapDataId.get(progressRow.MapDataId); - if (!ms) continue; - let entry = payloadProgressByTeam.get(progressRow.MapDataId); - if (!entry) { - entry = { team1: [], team2: [] }; - payloadProgressByTeam.set(progressRow.MapDataId, entry); - } - if (progressRow.capturing_team === ms.team_1_name) { - entry.team1.push(progressRow); - } else if (progressRow.capturing_team === ms.team_2_name) { - entry.team2.push(progressRow); - } - } - - const pointProgressByTeam = new Map< - number, - { team1: PointProgress[]; team2: PointProgress[] } - >(); - for (const progressRow of pointProgressRows) { - if (!progressRow.MapDataId) continue; - const ms = matchStartsByMapDataId.get(progressRow.MapDataId); - if (!ms) continue; - let entry = pointProgressByTeam.get(progressRow.MapDataId); - if (!entry) { - entry = { team1: [], team2: [] }; - pointProgressByTeam.set(progressRow.MapDataId, entry); - } - if (progressRow.capturing_team === ms.team_1_name) { - entry.team1.push(progressRow); - } else if (progressRow.capturing_team === ms.team_2_name) { - entry.team2.push(progressRow); - } - } - - const heroBansByMapDataId = new Map< - number, - { team: string; hero: string }[] - >(); - for (const ban of heroBans) { - if (!ban.MapDataId) continue; - let entry = heroBansByMapDataId.get(ban.MapDataId); - if (!entry) { - entry = []; - heroBansByMapDataId.set(ban.MapDataId, entry); - } - entry.push({ team: ban.team, hero: ban.hero }); - } - - const fullPlayerStatsByMapDataId = new Map< - number, - { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - eliminations: number; - deaths: number; - }[] - >(); - for (const stat of allPlayerStatsFull) { - if (!stat.MapDataId) continue; - let entry = fullPlayerStatsByMapDataId.get(stat.MapDataId); - if (!entry) { - entry = []; - fullPlayerStatsByMapDataId.set(stat.MapDataId, entry); - } - entry.push(stat); - } - - return { - mapDataRows, - allPlayerStats, - teamRosterSet, - matchStartsByMapDataId, - finalRoundsByMapDataId, - capturesByTeam, - payloadProgressByTeam, - pointProgressByTeam, - heroBansByMapDataId, - fullPlayerStatsByMapDataId, - }; -} - -const fetchTaggedScrimData = cache(fetchTaggedScrimDataFn); - -/** - * Determines the opponent's team string for a given MapData record. - * Returns null if the user team string cannot be resolved. - */ -function resolveOpponentTeamString( - mapDataId: number, - allPlayerStats: ScrimPlayerStatRow[], - teamRosterSet: Set, - matchStart: MatchStart | undefined -): string | null { - const userTeamString = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!userTeamString || !matchStart) return null; - - const team1 = matchStart.team_1_name; - const team2 = matchStart.team_2_name; - if (userTeamString === team1) return team2; - if (userTeamString === team2) return team1; - return null; -} - -async function getOpponentScrimMapResultsFn( - userTeamId: number, - opponentAbbr: string -): Promise { - const data = await fetchTaggedScrimData(userTeamId, opponentAbbr); - const { - mapDataRows, - allPlayerStats, - teamRosterSet, - matchStartsByMapDataId, - finalRoundsByMapDataId, - capturesByTeam, - payloadProgressByTeam, - pointProgressByTeam, - } = data; - - const results: ScrimMapResult[] = []; - - for (const row of mapDataRows) { - const matchStart = matchStartsByMapDataId.get(row.id); - const userTeamString = findTeamNameForMapInMemory( - row.id, - allPlayerStats, - teamRosterSet - ); - if (!userTeamString || !matchStart) continue; - - const finalRound = finalRoundsByMapDataId.get(row.id) ?? null; - const captureEntry = capturesByTeam.get(row.id); - const payloadProgressEntry = payloadProgressByTeam.get(row.id); - const pointProgressEntry = pointProgressByTeam.get(row.id); - const winner = calculateWinner({ - matchDetails: matchStart, - finalRound, - team1Captures: captureEntry?.team1 ?? [], - team2Captures: captureEntry?.team2 ?? [], - team1PayloadProgress: payloadProgressEntry?.team1 ?? [], - team2PayloadProgress: payloadProgressEntry?.team2 ?? [], - team1PointProgress: pointProgressEntry?.team1 ?? [], - team2PointProgress: pointProgressEntry?.team2 ?? [], - }); - - if (winner === "N/A") continue; - - const mapType = matchStart.map_type ?? row.mapType; - if (!mapType) continue; - - results.push({ - mapName: row.mapName, - mapType, - scrimDate: row.scrimDate, - opponentWon: winner !== userTeamString, - source: "scrim", - }); - } - - return results; -} - -export const getOpponentScrimMapResults = cache(getOpponentScrimMapResultsFn); - -async function getOpponentScrimHeroBansFn( - userTeamId: number, - opponentAbbr: string -): Promise { - const data = await fetchTaggedScrimData(userTeamId, opponentAbbr); - const { - mapDataRows, - allPlayerStats, - teamRosterSet, - matchStartsByMapDataId, - finalRoundsByMapDataId, - capturesByTeam, - payloadProgressByTeam, - pointProgressByTeam, - heroBansByMapDataId, - } = data; - - const results: ScrimHeroBan[] = []; - - for (const row of mapDataRows) { - const matchStart = matchStartsByMapDataId.get(row.id); - const userTeamString = findTeamNameForMapInMemory( - row.id, - allPlayerStats, - teamRosterSet - ); - if (!userTeamString || !matchStart) continue; - - const opponentTeamString = resolveOpponentTeamString( - row.id, - allPlayerStats, - teamRosterSet, - matchStart - ); - if (!opponentTeamString) continue; - - const finalRound = finalRoundsByMapDataId.get(row.id) ?? null; - const captureEntry = capturesByTeam.get(row.id); - const payloadProgressEntry = payloadProgressByTeam.get(row.id); - const pointProgressEntry = pointProgressByTeam.get(row.id); - const winner = calculateWinner({ - matchDetails: matchStart, - finalRound, - team1Captures: captureEntry?.team1 ?? [], - team2Captures: captureEntry?.team2 ?? [], - team1PayloadProgress: payloadProgressEntry?.team1 ?? [], - team2PayloadProgress: payloadProgressEntry?.team2 ?? [], - team1PointProgress: pointProgressEntry?.team1 ?? [], - team2PointProgress: pointProgressEntry?.team2 ?? [], - }); - - if (winner === "N/A") continue; - - const mapType = matchStart.map_type ?? row.mapType; - if (!mapType) continue; - - const bans = heroBansByMapDataId.get(row.id) ?? []; - const opponentBans = bans - .filter((b) => b.team === opponentTeamString) - .map((b) => b.hero); - - results.push({ - mapName: row.mapName, - mapType, - scrimDate: row.scrimDate, - opponentWon: winner !== userTeamString, - opponentBans, - source: "scrim", - }); - } - - return results; -} - -export const getOpponentScrimHeroBans = cache(getOpponentScrimHeroBansFn); - -async function getOpponentScrimPlayerStatsFn( - userTeamId: number, - opponentAbbr: string -): Promise { - const data = await fetchTaggedScrimData(userTeamId, opponentAbbr); - const { - mapDataRows, - allPlayerStats, - teamRosterSet, - matchStartsByMapDataId, - fullPlayerStatsByMapDataId, - } = data; - - const aggregated = new Map< - string, - { - hero: string; - role: HeroRole; - timePlayed: number; - eliminations: number; - deaths: number; - } - >(); - - for (const row of mapDataRows) { - const matchStart = matchStartsByMapDataId.get(row.id); - const userTeamString = findTeamNameForMapInMemory( - row.id, - allPlayerStats, - teamRosterSet - ); - if (!userTeamString || !matchStart) continue; - - const opponentTeamString = resolveOpponentTeamString( - row.id, - allPlayerStats, - teamRosterSet, - matchStart - ); - if (!opponentTeamString) continue; - - const stats = fullPlayerStatsByMapDataId.get(row.id) ?? []; - for (const stat of stats) { - if (stat.player_team !== opponentTeamString) continue; - - const key = `${stat.player_name}__${stat.player_hero}`; - const role = heroRoleMapping[stat.player_hero as HeroName] ?? "Damage"; - const existing = aggregated.get(key); - if (existing) { - existing.timePlayed += stat.hero_time_played; - existing.eliminations += stat.eliminations; - existing.deaths += stat.deaths; - } else { - aggregated.set(key, { - hero: stat.player_hero, - role, - timePlayed: stat.hero_time_played, - eliminations: stat.eliminations, - deaths: stat.deaths, - }); - } - } - } - - return Array.from(aggregated.entries()).map(([key, value]) => ({ - playerName: key.split("__")[0], - hero: value.hero, - role: value.role, - timePlayed: value.timePlayed, - eliminations: value.eliminations, - deaths: value.deaths, - source: "scrim" as const, - })); -} - -export const getOpponentScrimPlayerStats = cache(getOpponentScrimPlayerStatsFn); diff --git a/src/data/scrim-overview-dto.ts b/src/data/scrim-overview-dto.ts deleted file mode 100644 index 1ed87f41a..000000000 --- a/src/data/scrim-overview-dto.ts +++ /dev/null @@ -1,2428 +0,0 @@ -import "server-only"; - -import type { TrendsAnalysis } from "@/data/comparison-dto"; -import { aggregatePlayerStats, calculateTrends } from "@/data/comparison-dto"; -import { - filterUtilityRoundStartSwaps, - type SwapRecord, - type SwapTimingOutcome, - type SwapWinrateBucket, -} from "@/data/team-hero-swap-dto"; -import { getTeamRoster } from "@/data/team-shared-data"; -import prisma from "@/lib/prisma"; -import type { ValidStatColumn } from "@/lib/stat-percentiles"; -import { - groupEventsIntoFights, - mercyRezToKillEvent, - removeDuplicateRows, - ultimateStartToKillEvent, - type Fight, -} from "@/lib/utils"; -import { calculateWinner } from "@/lib/winrate"; -import type { AbilityTimingAnalysis } from "@/data/scrim-ability-timing-dto"; -import { getScrimAbilityTiming } from "@/data/scrim-ability-timing-dto"; -import type { HeroName, RoleName, SubroleName } from "@/types/heroes"; -import { - getHeroRole, - heroRoleMapping, - ROLE_SUBROLES, - SUBROLE_DISPLAY_NAMES, - SUBROLE_ORDER, - subroleHeroMapping, -} from "@/types/heroes"; -import { - Prisma, - type CalculatedStat, - type Kill, - type MatchStart, - type ObjectiveCaptured, - type PlayerStat, - type RoundEnd, -} from "@prisma/client"; -import { cache } from "react"; - -export type ScrimOutlier = { - stat: ValidStatColumn; - zScore: number; - percentile: number; - direction: "high" | "low"; - label: string; -}; - -export type PlayerMapPerformance = { - mapName: string; - mapIndex: number; - kdRatio: number; - eliminationsPer10: number; - heroDamagePer10: number; - healingDealtPer10: number; - firstDeathRate: number; - teamFirstDeathRate: number; -}; - -export type PlayerScrimPerformance = { - playerKey: string; - playerName: string; - primaryHero: HeroName; - heroes: HeroName[]; - mapsPlayed: number; - eliminations: number; - deaths: number; - heroDamageDealt: number; - healingDealt: number; - heroTimePlayed: number; - kdRatio: number; - eliminationsPer10: number; - deathsPer10: number; - heroDamagePer10: number; - healingDealtPer10: number; - firstDeathCount: number; - firstDeathRate: number; - teamFirstDeathCount: number; - teamFirstDeathRate: number; - perMapPerformance: PlayerMapPerformance[]; - zScores: Partial>; - outliers: ScrimOutlier[]; - trend: "improving" | "stable" | "declining"; - trendData?: TrendsAnalysis; -}; - -export type ScrimInsight = { - type: - | "mvp" - | "most_improved" - | "most_declined" - | "outlier_positive" - | "outlier_negative"; - headline: string; - playerName?: string; -}; - -export type MapResult = { - mapId: number; - mapName: string; - winner: "our_team" | "opponent" | "draw"; -}; - -export type ScrimTeamTotals = { - eliminations: number; - deaths: number; - heroDamage: number; - healing: number; - kdRatio: number; -}; - -export type ScrimFightAnalysis = { - totalFights: number; - fightsWon: number; - fightWinrate: number; - - teamFirstDeathCount: number; - teamFirstDeathRate: number; - firstDeathWinrate: number; - - firstPickCount: number; - firstPickRate: number; - firstPickWinrate: number; - - firstUltCount: number; - firstUltRate: number; - firstUltWinrate: number; - opponentFirstUltCount: number; - opponentFirstUltWinrate: number; -}; - -export type SubroleUltTiming = { - subrole: string; - count: number; - initiation: number; - midfight: number; - late: number; -}; - -export type ScrimUltRoleBreakdown = { - role: "Tank" | "Damage" | "Support"; - ourCount: number; - opponentCount: number; - ourFirstRate: number; - ourSubroleTimings: SubroleUltTiming[]; - opponentSubroleTimings: SubroleUltTiming[]; -}; - -export type PlayerUltComparison = { - subrole: string; - ourPlayerName: string; - ourHero: string; - ourUltCount: number; - opponentPlayerName: string; - opponentHero: string; - opponentUltCount: number; -}; - -export type FightInitiatingUlt = { - hero: string; - count: number; - isOurTeam: boolean; -}; - -export type UltEfficiency = { - ultimateEfficiency: number; - avgUltsInWonFights: number; - avgUltsInLostFights: number; - wastedUltimates: number; - totalUltsUsedInFights: number; - fightsWon: number; - fightsLost: number; - dryFights: number; - dryFightWins: number; - dryFightWinrate: number; - dryFightReversals: number; - dryFightReversalRate: number; - nonDryFights: number; - nonDryFightReversals: number; - nonDryFightReversalRate: number; -}; - -export type ScrimUltAnalysis = { - ourUltsUsed: number; - opponentUltsUsed: number; - ultsByRole: ScrimUltRoleBreakdown[]; - topUltUser: { playerName: string; hero: string; count: number } | null; - avgChargeTime: number; - avgHoldTime: number; - playerComparisons: PlayerUltComparison[]; - ourFightInitiations: number; - opponentFightInitiations: number; - fightsWithUlts: number; - ourTopFightInitiator: FightInitiatingUlt | null; - opponentTopFightInitiator: FightInitiatingUlt | null; - ultEfficiency: UltEfficiency; -}; - -export type ScrimSwapAnalysis = { - ourSwaps: number; - opponentSwaps: number; - ourSwapsPerMap: number; - opponentSwapsPerMap: number; - mapsWithOurSwaps: number; - mapsWithoutOurSwaps: number; - noSwapWinrate: number; - noSwapWins: number; - noSwapLosses: number; - swapWinrate: number; - swapWins: number; - swapLosses: number; - avgHeroTimeBeforeSwap: number; - ourTopSwap: { from: string; to: string; count: number } | null; - opponentTopSwap: { from: string; to: string; count: number } | null; - topSwapper: { - playerName: string; - count: number; - mapsCount: number; - } | null; - winrateBySwapCount: SwapWinrateBucket[]; - timingOutcomes: SwapTimingOutcome[]; -}; - -export type ScrimOverviewData = { - mapCount: number; - wins: number; - losses: number; - draws: number; - ourTeamName: string; - opponentTeamName: string; - mapResults: MapResult[]; - teamPlayers: PlayerScrimPerformance[]; - insights: ScrimInsight[]; - teamTotals: ScrimTeamTotals; - fightAnalysis: ScrimFightAnalysis; - ultAnalysis: ScrimUltAnalysis; - swapAnalysis: ScrimSwapAnalysis; - abilityTimingAnalysis: AbilityTimingAnalysis; -}; - -const STAT_LABELS: Record = { - eliminations: "Eliminations", - final_blows: "Final Blows", - deaths: "Deaths", - hero_damage_dealt: "Hero Damage", - healing_dealt: "Healing", - healing_received: "Healing Received", - damage_blocked: "Damage Blocked", - damage_taken: "Damage Taken", - solo_kills: "Solo Kills", - ultimates_earned: "Ults Earned", - ultimates_used: "Ults Used", - objective_kills: "Objective Kills", - offensive_assists: "Offensive Assists", - defensive_assists: "Defensive Assists", -}; - -function statLabelFor(stat: ValidStatColumn): string { - return STAT_LABELS[stat] ?? stat; -} - -function statsForHeroRole( - primaryHero: HeroName, - heroDamageDealt: number, - healingDealt: number, - damageBlocked: number, - deaths: number, - eliminations: number -): { stat: ValidStatColumn; value: number }[] { - const role = heroRoleMapping[primaryHero]; - const base: { stat: ValidStatColumn; value: number }[] = [ - { stat: "eliminations", value: eliminations }, - { stat: "deaths", value: deaths }, - { stat: "hero_damage_dealt", value: heroDamageDealt }, - ]; - - if (role === "Support") { - base.push({ stat: "healing_dealt", value: healingDealt }); - } else if (role === "Tank") { - base.push({ stat: "damage_blocked", value: damageBlocked }); - } - - return base; -} - -function determineTrend( - trendData?: TrendsAnalysis -): "improving" | "stable" | "declining" { - if (!trendData) return "stable"; - const improving = trendData.improvingMetrics.length; - const declining = trendData.decliningMetrics.length; - if (improving > declining && improving > 0) return "improving"; - if (declining > improving && declining > 0) return "declining"; - return "stable"; -} - -function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { - const insights: ScrimInsight[] = []; - - if (players.length === 0) return insights; - - // MVP: highest eliminations per 10 among players with 2+ maps - const eligibleForMvp = players.filter((p) => p.mapsPlayed >= 2); - if (eligibleForMvp.length > 0) { - const mvp = eligibleForMvp.reduce((best, p) => - p.eliminationsPer10 > best.eliminationsPer10 ? p : best - ); - if (mvp.eliminationsPer10 > 0) { - insights.push({ - type: "mvp", - headline: `${mvp.playerName} led with ${mvp.eliminationsPer10.toFixed(1)} elims/10 min on ${mvp.primaryHero}`, - playerName: mvp.playerName, - }); - } - } - - // Most improved: player with trendData showing the most improving metrics - const mostImproved = players - .filter((p) => p.trendData && p.trendData.improvingMetrics.length > 0) - .sort( - (a, b) => - (b.trendData?.improvingMetrics.length ?? 0) - - (a.trendData?.improvingMetrics.length ?? 0) - )[0]; - - if (mostImproved) { - const topMetric = mostImproved.trendData!.improvingMetrics[0]; - const sign = topMetric.changePercentage > 0 ? "+" : ""; - insights.push({ - type: "most_improved", - headline: `${mostImproved.playerName} improved across maps (+${sign}${topMetric.changePercentage.toFixed(0)}% ${topMetric.metric.toLowerCase()})`, - playerName: mostImproved.playerName, - }); - } - - // Most declined - const mostDeclined = players - .filter((p) => p.trendData && p.trendData.decliningMetrics.length > 0) - .sort( - (a, b) => - (b.trendData?.decliningMetrics.length ?? 0) - - (a.trendData?.decliningMetrics.length ?? 0) - )[0]; - - if (mostDeclined && mostDeclined.playerName !== mostImproved?.playerName) { - const topMetric = mostDeclined.trendData!.decliningMetrics[0]; - insights.push({ - type: "most_declined", - headline: `${mostDeclined.playerName} declined across maps (${topMetric.changePercentage.toFixed(0)}% ${topMetric.metric.toLowerCase()})`, - playerName: mostDeclined.playerName, - }); - } - - // Positive outlier: highest z-score across all players/stats - const positiveOutliers = players - .flatMap((p) => - p.outliers - .filter((o) => o.direction === "high") - .map((o) => ({ player: p, outlier: o })) - ) - .sort((a, b) => b.outlier.zScore - a.outlier.zScore); - - if (positiveOutliers.length > 0) { - const { player, outlier } = positiveOutliers[0]; - insights.push({ - type: "outlier_positive", - headline: `${player.playerName}'s ${statLabelFor(outlier.stat).toLowerCase()} ranks at the ${outlier.percentile}th percentile vs database`, - playerName: player.playerName, - }); - } - - // Negative outlier: most concerning stat - const negativeOutliers = players - .flatMap((p) => - p.outliers - .filter((o) => o.direction === "low") - .map((o) => ({ player: p, outlier: o })) - ) - .sort((a, b) => a.outlier.zScore - b.outlier.zScore); - - if (negativeOutliers.length > 0 && insights.length < 4) { - const { player, outlier } = negativeOutliers[0]; - insights.push({ - type: "outlier_negative", - headline: `${player.playerName}'s ${statLabelFor(outlier.stat).toLowerCase()} is below average for ${player.primaryHero} (${outlier.percentile}th %ile)`, - playerName: player.playerName, - }); - } - - return insights.slice(0, 4); -} - -function emptyFightAnalysis(): ScrimFightAnalysis { - return { - totalFights: 0, - fightsWon: 0, - fightWinrate: 0, - teamFirstDeathCount: 0, - teamFirstDeathRate: 0, - firstDeathWinrate: 0, - firstPickCount: 0, - firstPickRate: 0, - firstPickWinrate: 0, - firstUltCount: 0, - firstUltRate: 0, - firstUltWinrate: 0, - opponentFirstUltCount: 0, - opponentFirstUltWinrate: 0, - }; -} - -function emptyUltAnalysis(): ScrimUltAnalysis { - return { - ourUltsUsed: 0, - opponentUltsUsed: 0, - ultsByRole: [], - topUltUser: null, - avgChargeTime: 0, - avgHoldTime: 0, - playerComparisons: [], - ourFightInitiations: 0, - opponentFightInitiations: 0, - fightsWithUlts: 0, - ourTopFightInitiator: null, - opponentTopFightInitiator: null, - ultEfficiency: { - ultimateEfficiency: 0, - avgUltsInWonFights: 0, - avgUltsInLostFights: 0, - wastedUltimates: 0, - totalUltsUsedInFights: 0, - fightsWon: 0, - fightsLost: 0, - dryFights: 0, - dryFightWins: 0, - dryFightWinrate: 0, - dryFightReversals: 0, - dryFightReversalRate: 0, - nonDryFights: 0, - nonDryFightReversals: 0, - nonDryFightReversalRate: 0, - }, - }; -} - -function emptySwapAnalysis(): ScrimSwapAnalysis { - return { - ourSwaps: 0, - opponentSwaps: 0, - ourSwapsPerMap: 0, - opponentSwapsPerMap: 0, - mapsWithOurSwaps: 0, - mapsWithoutOurSwaps: 0, - noSwapWinrate: 0, - noSwapWins: 0, - noSwapLosses: 0, - swapWinrate: 0, - swapWins: 0, - swapLosses: 0, - avgHeroTimeBeforeSwap: 0, - ourTopSwap: null, - opponentTopSwap: null, - topSwapper: null, - winrateBySwapCount: [], - timingOutcomes: [], - }; -} - -function emptyOverviewData(): ScrimOverviewData { - return { - mapCount: 0, - wins: 0, - losses: 0, - draws: 0, - ourTeamName: "", - opponentTeamName: "", - mapResults: [], - teamPlayers: [], - insights: [], - teamTotals: { - eliminations: 0, - deaths: 0, - heroDamage: 0, - healing: 0, - kdRatio: 0, - }, - fightAnalysis: emptyFightAnalysis(), - ultAnalysis: emptyUltAnalysis(), - swapAnalysis: emptySwapAnalysis(), - abilityTimingAnalysis: { rows: [], outliers: [] }, - }; -} - -function aggregateRowsToPlayerStat(rows: PlayerStat[]): PlayerStat | null { - if (rows.length === 0) return null; - const base = rows[0]; - const totals = rows.reduce( - (acc, row) => { - acc.eliminations += row.eliminations; - acc.final_blows += row.final_blows; - acc.deaths += row.deaths; - acc.all_damage_dealt += row.all_damage_dealt; - acc.barrier_damage_dealt += row.barrier_damage_dealt; - acc.hero_damage_dealt += row.hero_damage_dealt; - acc.healing_dealt += row.healing_dealt; - acc.healing_received += row.healing_received; - acc.self_healing += row.self_healing; - acc.damage_taken += row.damage_taken; - acc.damage_blocked += row.damage_blocked; - acc.defensive_assists += row.defensive_assists; - acc.offensive_assists += row.offensive_assists; - acc.ultimates_earned += row.ultimates_earned; - acc.ultimates_used += row.ultimates_used; - acc.multikill_best = Math.max(acc.multikill_best, row.multikill_best); - acc.multikills += row.multikills; - acc.solo_kills += row.solo_kills; - acc.objective_kills += row.objective_kills; - acc.environmental_kills += row.environmental_kills; - acc.environmental_deaths += row.environmental_deaths; - acc.critical_hits += row.critical_hits; - acc.scoped_critical_hit_kills += row.scoped_critical_hit_kills; - acc.shots_fired += row.shots_fired; - acc.shots_hit += row.shots_hit; - acc.shots_missed += row.shots_missed; - acc.scoped_shots += row.scoped_shots; - acc.scoped_shots_hit += row.scoped_shots_hit; - acc.hero_time_played += row.hero_time_played; - return acc; - }, - { - eliminations: 0, - final_blows: 0, - deaths: 0, - all_damage_dealt: 0, - barrier_damage_dealt: 0, - hero_damage_dealt: 0, - healing_dealt: 0, - healing_received: 0, - self_healing: 0, - damage_taken: 0, - damage_blocked: 0, - defensive_assists: 0, - offensive_assists: 0, - ultimates_earned: 0, - ultimates_used: 0, - multikill_best: 0, - multikills: 0, - solo_kills: 0, - objective_kills: 0, - environmental_kills: 0, - environmental_deaths: 0, - critical_hits: 0, - scoped_critical_hit_kills: 0, - shots_fired: 0, - shots_hit: 0, - shots_missed: 0, - scoped_shots: 0, - scoped_shots_hit: 0, - hero_time_played: 0, - } - ); - return { ...base, ...totals }; -} - -function getMapTeamNames(stats: PlayerStat[]): Map { - const countsByMap = new Map>(); - for (const row of stats) { - if (!row.MapDataId) continue; - if (!countsByMap.has(row.MapDataId)) { - countsByMap.set(row.MapDataId, new Map()); - } - const mapCounts = countsByMap.get(row.MapDataId)!; - mapCounts.set(row.player_team, (mapCounts.get(row.player_team) ?? 0) + 1); - } - - const result = new Map(); - for (const [mapId, teamCounts] of countsByMap.entries()) { - let selectedTeam: string | null = null; - let maxCount = -1; - for (const [teamName, count] of teamCounts.entries()) { - if (count > maxCount) { - maxCount = count; - selectedTeam = teamName; - } - } - if (selectedTeam) { - result.set(mapId, selectedTeam); - } - } - return result; -} - -function createBaselineKey(hero: HeroName, stat: ValidStatColumn): string { - return `${hero}::${stat}`; -} - -function erf(x: number): number { - const sign = x >= 0 ? 1 : -1; - const absX = Math.abs(x); - const a1 = 0.254829592; - const a2 = -0.284496736; - const a3 = 1.421413741; - const a4 = -1.453152027; - const a5 = 1.061405429; - const p = 0.3275911; - const t = 1 / (1 + p * absX); - const y = - 1 - - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-absX * absX); - return sign * y; -} - -function normalCdf(z: number): number { - return 0.5 * (1 + erf(z / Math.SQRT2)); -} - -type StatDistributionBaseline = { - hero: string; - stat_name: string; - hero_avg_per10: number; - hero_std_per10: number; - total_players: number; -}; - -type BatchedBaselineRow = { - hero: string; - avg_eliminations_per10: number | null; - std_eliminations_per10: number | null; - avg_deaths_per10: number | null; - std_deaths_per10: number | null; - avg_hero_damage_dealt_per10: number | null; - std_hero_damage_dealt_per10: number | null; - avg_healing_dealt_per10: number | null; - std_healing_dealt_per10: number | null; - avg_damage_blocked_per10: number | null; - std_damage_blocked_per10: number | null; - total_players: number; -}; - -const BASELINE_STAT_COLUMNS: ValidStatColumn[] = [ - "eliminations", - "deaths", - "hero_damage_dealt", - "healing_dealt", - "damage_blocked", -]; - -async function getBatchedStatDistributionBaselines( - heroes: HeroName[], - minMaps = 5, - minTimeSeconds = 300, - sampleLimit = 150 -): Promise> { - if (heroes.length === 0) return new Map(); - - const rows = await prisma.$queryRaw` - WITH - final_rows AS ( - SELECT DISTINCT ON ("MapDataId", player_name, player_hero) - player_name, - player_hero, - eliminations, - deaths, - hero_damage_dealt, - healing_dealt, - damage_blocked, - hero_time_played - FROM - "PlayerStat" - WHERE - player_hero IN (${Prisma.join(heroes)}) - AND hero_time_played >= 60 - ORDER BY - "MapDataId", - player_name, - player_hero, - round_number DESC, - id DESC - ), - per_player_totals AS ( - SELECT - player_hero, - player_name, - COUNT(*) AS maps, - (SUM(eliminations)::numeric / SUM(hero_time_played)) * 600.0 AS elims_per10, - (SUM(deaths)::numeric / SUM(hero_time_played)) * 600.0 AS deaths_per10, - (SUM(hero_damage_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS dmg_per10, - (SUM(healing_dealt)::numeric / SUM(hero_time_played)) * 600.0 AS heal_per10, - (SUM(damage_blocked)::numeric / SUM(hero_time_played)) * 600.0 AS block_per10 - FROM - final_rows - GROUP BY - player_name, player_hero - HAVING - COUNT(*) >= ${minMaps} - AND SUM(hero_time_played) >= ${minTimeSeconds} - ), - ranked AS ( - SELECT - *, - ROW_NUMBER() OVER ( - PARTITION BY player_hero - ORDER BY maps DESC, player_name - ) AS rn - FROM - per_player_totals - ), - limited AS ( - SELECT * FROM ranked WHERE rn <= ${sampleLimit} - ) - SELECT - player_hero AS hero, - ROUND(AVG(elims_per10)::numeric, 2) AS avg_eliminations_per10, - ROUND(STDDEV_SAMP(elims_per10)::numeric, 2) AS std_eliminations_per10, - ROUND(AVG(deaths_per10)::numeric, 2) AS avg_deaths_per10, - ROUND(STDDEV_SAMP(deaths_per10)::numeric, 2) AS std_deaths_per10, - ROUND(AVG(dmg_per10)::numeric, 2) AS avg_hero_damage_dealt_per10, - ROUND(STDDEV_SAMP(dmg_per10)::numeric, 2) AS std_hero_damage_dealt_per10, - ROUND(AVG(heal_per10)::numeric, 2) AS avg_healing_dealt_per10, - ROUND(STDDEV_SAMP(heal_per10)::numeric, 2) AS std_healing_dealt_per10, - ROUND(AVG(block_per10)::numeric, 2) AS avg_damage_blocked_per10, - ROUND(STDDEV_SAMP(block_per10)::numeric, 2) AS std_damage_blocked_per10, - COUNT(*)::int AS total_players - FROM - limited - GROUP BY - player_hero - `; - - const statColumnMapping: Record< - string, - { avg: keyof BatchedBaselineRow; std: keyof BatchedBaselineRow } - > = { - eliminations: { - avg: "avg_eliminations_per10", - std: "std_eliminations_per10", - }, - deaths: { avg: "avg_deaths_per10", std: "std_deaths_per10" }, - hero_damage_dealt: { - avg: "avg_hero_damage_dealt_per10", - std: "std_hero_damage_dealt_per10", - }, - healing_dealt: { - avg: "avg_healing_dealt_per10", - std: "std_healing_dealt_per10", - }, - damage_blocked: { - avg: "avg_damage_blocked_per10", - std: "std_damage_blocked_per10", - }, - }; - - const result = new Map(); - for (const row of rows) { - for (const stat of BASELINE_STAT_COLUMNS) { - const mapping = statColumnMapping[stat]; - const avg = Number(row[mapping.avg]); - const std = Number(row[mapping.std]); - if (Number.isNaN(avg) || Number.isNaN(std)) continue; - - result.set(createBaselineKey(row.hero as HeroName, stat), { - hero: row.hero, - stat_name: stat, - hero_avg_per10: avg, - hero_std_per10: std, - total_players: row.total_players, - }); - } - } - - return result; -} - -function calculateZScoreFromBaseline({ - baseline, - stat, - per10Value, -}: { - baseline: StatDistributionBaseline; - stat: ValidStatColumn; - per10Value: number; -}): number | null { - if (!baseline.hero_std_per10 || baseline.hero_std_per10 === 0) return null; - const isInverted = stat === "deaths" || stat === "damage_taken"; - const zScore = isInverted - ? (baseline.hero_avg_per10 - per10Value) / baseline.hero_std_per10 - : (per10Value - baseline.hero_avg_per10) / baseline.hero_std_per10; - if (!Number.isFinite(zScore)) return null; - return zScore; -} - -type ExtendedFightEvent = Kill & { - ultimate_id?: number; -}; - -type ExtendedFight = { - events: ExtendedFightEvent[]; - start: number; - end: number; -}; - -const FIGHT_GAP_SECONDS = 15; - -function computeScrimFightAnalysis( - mapIds: number[], - killsByMap: Map, - allUltimates: { - id: number; - scrimId: number; - match_time: number; - player_team: string; - player_name: string; - player_hero: string; - ultimate_id: number; - MapDataId: number | null; - }[], - ourTeamNameByMap: Map -): ScrimFightAnalysis { - const ultsByMap = new Map(); - for (const ult of allUltimates) { - if (!ult.MapDataId) continue; - if (!ultsByMap.has(ult.MapDataId)) { - ultsByMap.set(ult.MapDataId, []); - } - ultsByMap.get(ult.MapDataId)!.push(ult); - } - - let totalFights = 0; - let fightsWon = 0; - let teamFirstDeathCount = 0; - let firstDeathWins = 0; - let firstPickCount = 0; - let firstPickWins = 0; - let firstUltCount = 0; - let firstUltWins = 0; - let opponentFirstUltCount = 0; - let opponentFirstUltWins = 0; - - for (const mapId of mapIds) { - const ourTeamName = ourTeamNameByMap.get(mapId); - if (!ourTeamName) continue; - - const kills = killsByMap.get(mapId) ?? []; - const ults = ultsByMap.get(mapId) ?? []; - - if (kills.length === 0 && ults.length === 0) continue; - - const events: ExtendedFightEvent[] = [ - ...kills, - ...ults.map((ult) => ({ - ...ultimateStartToKillEvent(ult), - ultimate_id: ult.ultimate_id, - })), - ]; - - events.sort((a, b) => a.match_time - b.match_time); - - const fights: ExtendedFight[] = []; - let currentFight: ExtendedFight | null = null; - - for (const event of events) { - if ( - !currentFight || - event.match_time - currentFight.end > FIGHT_GAP_SECONDS - ) { - currentFight = { - events: [event], - start: event.match_time, - end: event.match_time, - }; - fights.push(currentFight); - } else { - currentFight.events.push(event); - currentFight.end = event.match_time; - } - } - - for (const fight of fights) { - const sorted = [...fight.events].sort( - (a, b) => a.match_time - b.match_time - ); - - const killEvents = sorted.filter( - (e) => e.event_type === "kill" || e.event_type === "mercy_rez" - ); - const ultEvents = sorted.filter((e) => e.event_type === "ultimate_start"); - - let ourKills = 0; - let enemyKills = 0; - - for (const event of sorted) { - if (event.event_type === "mercy_rez") { - if (event.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } - } - } - - const won = ourKills > enemyKills; - - totalFights++; - if (won) fightsWon++; - - const firstKill = killEvents.find((k) => k.event_type === "kill"); - if (firstKill) { - if (firstKill.attacker_team === ourTeamName) { - firstPickCount++; - if (won) firstPickWins++; - } - if (firstKill.victim_team === ourTeamName) { - teamFirstDeathCount++; - if (won) firstDeathWins++; - } - } - - const firstUlt = ultEvents[0]; - if (firstUlt) { - if (firstUlt.attacker_team === ourTeamName) { - firstUltCount++; - if (won) firstUltWins++; - } else { - opponentFirstUltCount++; - if (won) opponentFirstUltWins++; - } - } - } - } - - return { - totalFights, - fightsWon, - fightWinrate: totalFights > 0 ? (fightsWon / totalFights) * 100 : 0, - teamFirstDeathCount, - teamFirstDeathRate: - totalFights > 0 ? (teamFirstDeathCount / totalFights) * 100 : 0, - firstDeathWinrate: - teamFirstDeathCount > 0 - ? (firstDeathWins / teamFirstDeathCount) * 100 - : 0, - firstPickCount, - firstPickRate: totalFights > 0 ? (firstPickCount / totalFights) * 100 : 0, - firstPickWinrate: - firstPickCount > 0 ? (firstPickWins / firstPickCount) * 100 : 0, - firstUltCount, - firstUltRate: totalFights > 0 ? (firstUltCount / totalFights) * 100 : 0, - firstUltWinrate: - firstUltCount > 0 ? (firstUltWins / firstUltCount) * 100 : 0, - opponentFirstUltCount, - opponentFirstUltWinrate: - opponentFirstUltCount > 0 - ? (opponentFirstUltWins / opponentFirstUltCount) * 100 - : 0, - }; -} - -type UltStartRecord = { - player_team: string; - player_name: string; - player_hero: string; - match_time: number; - MapDataId: number | null; -}; - -function computeScrimUltAnalysis( - allUltimates: UltStartRecord[], - fightsByMap: Map, - ourTeamNameByMap: Map, - calculatedStats: CalculatedStat[], - scrimPlayers: string[] -): ScrimUltAnalysis { - const roles: RoleName[] = ["Tank", "Damage", "Support"]; - - let ourUltsUsed = 0; - let opponentUltsUsed = 0; - const ourByRole: Record = { - Tank: 0, - Damage: 0, - Support: 0, - }; - const oppByRole: Record = { - Tank: 0, - Damage: 0, - Support: 0, - }; - const firstByRole: Record< - RoleName, - { ourFirst: number; oppFirst: number; total: number } - > = { - Tank: { ourFirst: 0, oppFirst: 0, total: 0 }, - Damage: { ourFirst: 0, oppFirst: 0, total: 0 }, - Support: { ourFirst: 0, oppFirst: 0, total: 0 }, - }; - - type PlayerUltEntry = { - heroCountMap: Map; - totalCount: number; - }; - const ourPlayerUltCounts = new Map(); - const oppPlayerUltCounts = new Map(); - - function trackPlayerUlt( - map: Map, - playerName: string, - hero: string - ) { - let entry = map.get(playerName); - if (!entry) { - entry = { heroCountMap: new Map(), totalCount: 0 }; - map.set(playerName, entry); - } - entry.totalCount++; - entry.heroCountMap.set(hero, (entry.heroCountMap.get(hero) ?? 0) + 1); - } - - type SubroleTimingAccum = { - count: number; - initiation: number; - midfight: number; - late: number; - }; - - function createSubroleTimingMap() { - const map = new Map>(); - for (const role of roles) { - const inner = new Map(); - for (const sr of ROLE_SUBROLES[role]) { - inner.set(sr, { count: 0, initiation: 0, midfight: 0, late: 0 }); - } - map.set(role, inner); - } - return map; - } - - const ourSubroleTimingByRole = createSubroleTimingMap(); - const oppSubroleTimingByRole = createSubroleTimingMap(); - - const fightInitiatingUlts = new Map< - string, - { hero: string; count: number; ourCount: number; oppCount: number } - >(); - let ourFightInitiations = 0; - let opponentFightInitiations = 0; - let fightsWithUlts = 0; - - let effFightsWon = 0; - let effFightsLost = 0; - let ultsInWonFights = 0; - let ultsInLostFights = 0; - let totalWastedUlts = 0; - let totalOurUltsInFights = 0; - let dryFights = 0; - let dryFightWins = 0; - let dryFightReversals = 0; - let nonDryFightReversals = 0; - - const ultsByMap = new Map(); - for (const ult of allUltimates) { - if (!ult.MapDataId) continue; - if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); - ultsByMap.get(ult.MapDataId)!.push(ult); - } - - // Pre-compute player ult counts for subrole assignment - for (const [mapId, ults] of ultsByMap) { - const ourTeamName = ourTeamNameByMap.get(mapId); - if (!ourTeamName) continue; - for (const ult of ults) { - if (ult.player_team === ourTeamName) { - trackPlayerUlt(ourPlayerUltCounts, ult.player_name, ult.player_hero); - } else { - trackPlayerUlt(oppPlayerUltCounts, ult.player_name, ult.player_hero); - } - } - } - - // Build player-name → subrole lookup from the same assignment algorithm - // used by the comparison chart, so both charts agree on subrole placement - const ourSubroleAssignment = assignPlayersToSubroles(ourPlayerUltCounts); - const oppSubroleAssignment = assignPlayersToSubroles(oppPlayerUltCounts); - - const ourPlayerSubrole = new Map(); - for (const [sr, candidate] of ourSubroleAssignment) { - ourPlayerSubrole.set(candidate.playerName, sr); - } - const oppPlayerSubrole = new Map(); - for (const [sr, candidate] of oppSubroleAssignment) { - oppPlayerSubrole.set(candidate.playerName, sr); - } - - for (const [mapId, ults] of ultsByMap) { - const ourTeamName = ourTeamNameByMap.get(mapId); - if (!ourTeamName) continue; - - for (const ult of ults) { - const role = getHeroRole(ult.player_hero); - if (ult.player_team === ourTeamName) { - ourUltsUsed++; - ourByRole[role]++; - } else { - opponentUltsUsed++; - oppByRole[role]++; - } - } - - const fights = fightsByMap.get(mapId) ?? []; - for (const fight of fights) { - const fightUlts = ults.filter( - (u) => u.match_time >= fight.start && u.match_time <= fight.end + 15 - ); - - if (fightUlts.length > 0) { - const opener = fightUlts[0]; - fightsWithUlts++; - const isOurs = opener.player_team === ourTeamName; - if (isOurs) ourFightInitiations++; - else opponentFightInitiations++; - - const key = opener.player_hero; - const existing = fightInitiatingUlts.get(key); - if (existing) { - existing.count++; - if (isOurs) existing.ourCount++; - else existing.oppCount++; - } else { - fightInitiatingUlts.set(key, { - hero: opener.player_hero, - count: 1, - ourCount: isOurs ? 1 : 0, - oppCount: isOurs ? 0 : 1, - }); - } - } - - for (const role of roles) { - const first = fightUlts.find( - (u) => getHeroRole(u.player_hero) === role - ); - if (!first) continue; - firstByRole[role].total++; - if (first.player_team === ourTeamName) firstByRole[role].ourFirst++; - else firstByRole[role].oppFirst++; - } - - const fightDuration = fight.end + 15 - fight.start; - const thirdDuration = fightDuration / 3; - for (const ult of fightUlts) { - const isOurUlt = ult.player_team === ourTeamName; - const playerLookup = isOurUlt ? ourPlayerSubrole : oppPlayerSubrole; - const subrole = playerLookup.get(ult.player_name); - if (!subrole) continue; - const role = getHeroRole(ult.player_hero); - - const timingMap = isOurUlt - ? ourSubroleTimingByRole - : oppSubroleTimingByRole; - const accum = timingMap.get(role)?.get(subrole); - if (!accum) continue; - accum.count++; - - const elapsed = ult.match_time - fight.start; - if (fightDuration <= 0 || elapsed < thirdDuration) { - accum.initiation++; - } else if (elapsed < thirdDuration * 2) { - accum.midfight++; - } else { - accum.late++; - } - } - - const ourUltCount = fightUlts.filter( - (u) => u.player_team === ourTeamName - ).length; - - let ourKills = 0; - let enemyKills = 0; - let wasDown2Plus = false; - for (const k of fight.kills) { - if (k.event_type === "mercy_rez") { - if (k.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (k.event_type === "kill") { - if (k.attacker_team === ourTeamName) ourKills++; - else enemyKills++; - } - if (enemyKills - ourKills >= 2) { - wasDown2Plus = true; - } - } - const won = ourKills > enemyKills; - const isReversal = won && wasDown2Plus; - - if (ourUltCount === 0) { - dryFights++; - if (won) dryFightWins++; - if (isReversal) dryFightReversals++; - } else { - totalOurUltsInFights += ourUltCount; - if (won) { - effFightsWon++; - ultsInWonFights += ourUltCount; - } else { - effFightsLost++; - ultsInLostFights += ourUltCount; - } - if (isReversal) nonDryFightReversals++; - - const sortedKills = [...fight.kills].sort( - (a, b) => a.match_time - b.match_time - ); - for (const ult of fightUlts) { - if (ult.player_team !== ourTeamName) continue; - let runOur = 0; - let runEnemy = 0; - for (const k of sortedKills) { - if (k.match_time > ult.match_time) break; - if (k.event_type === "mercy_rez") { - if (k.victim_team === ourTeamName) - runEnemy = Math.max(0, runEnemy - 1); - else runOur = Math.max(0, runOur - 1); - } else if (k.event_type === "kill") { - if (k.attacker_team === ourTeamName) runOur++; - else runEnemy++; - } - } - if (runOur - runEnemy <= -3) totalWastedUlts++; - } - } - } - } - - function extractTimings( - timingMap: Map>, - role: RoleName - ): SubroleUltTiming[] { - const result: SubroleUltTiming[] = []; - const inner = timingMap.get(role)!; - for (const sr of ROLE_SUBROLES[role]) { - const accum = inner.get(sr)!; - if (accum.count > 0) { - result.push({ - subrole: SUBROLE_DISPLAY_NAMES[sr], - count: accum.count, - initiation: accum.initiation, - midfight: accum.midfight, - late: accum.late, - }); - } - } - return result; - } - - const ultsByRole: ScrimUltRoleBreakdown[] = roles.map((role) => ({ - role, - ourCount: ourByRole[role], - opponentCount: oppByRole[role], - ourFirstRate: - firstByRole[role].total > 0 - ? (firstByRole[role].ourFirst / firstByRole[role].total) * 100 - : 0, - ourSubroleTimings: extractTimings(ourSubroleTimingByRole, role), - opponentSubroleTimings: extractTimings(oppSubroleTimingByRole, role), - })); - - let topUltUser: ScrimUltAnalysis["topUltUser"] = null; - for (const [name, entry] of ourPlayerUltCounts) { - if (!topUltUser || entry.totalCount > topUltUser.count) { - let bestHero = ""; - let bestCount = 0; - for (const [hero, count] of entry.heroCountMap) { - if (count > bestCount) { - bestCount = count; - bestHero = hero; - } - } - topUltUser = { - playerName: name, - hero: bestHero, - count: entry.totalCount, - }; - } - } - - const scrimPlayerSet = new Set(scrimPlayers); - const chargeTimeValues: number[] = []; - const holdTimeValues: number[] = []; - for (const cs of calculatedStats) { - if (!scrimPlayerSet.has(cs.playerName) || cs.value <= 0) continue; - if (cs.stat === "AVERAGE_ULT_CHARGE_TIME") chargeTimeValues.push(cs.value); - else if (cs.stat === "AVERAGE_TIME_TO_USE_ULT") - holdTimeValues.push(cs.value); - } - - const avgChargeTime = - chargeTimeValues.length > 0 - ? chargeTimeValues.reduce((a, b) => a + b, 0) / chargeTimeValues.length - : 0; - const avgHoldTime = - holdTimeValues.length > 0 - ? holdTimeValues.reduce((a, b) => a + b, 0) / holdTimeValues.length - : 0; - - const playerComparisons = buildPlayerUltComparisons( - ourPlayerUltCounts, - oppPlayerUltCounts - ); - - let ourTopFightInitiator: FightInitiatingUlt | null = null; - let opponentTopFightInitiator: FightInitiatingUlt | null = null; - for (const entry of fightInitiatingUlts.values()) { - if ( - entry.ourCount > 0 && - (!ourTopFightInitiator || entry.ourCount > ourTopFightInitiator.count) - ) { - ourTopFightInitiator = { - hero: entry.hero, - count: entry.ourCount, - isOurTeam: true, - }; - } - if ( - entry.oppCount > 0 && - (!opponentTopFightInitiator || - entry.oppCount > opponentTopFightInitiator.count) - ) { - opponentTopFightInitiator = { - hero: entry.hero, - count: entry.oppCount, - isOurTeam: false, - }; - } - } - - const nonDryFights = effFightsWon + effFightsLost; - const ultEfficiency: UltEfficiency = { - ultimateEfficiency: - totalOurUltsInFights > 0 ? effFightsWon / totalOurUltsInFights : 0, - avgUltsInWonFights: effFightsWon > 0 ? ultsInWonFights / effFightsWon : 0, - avgUltsInLostFights: - effFightsLost > 0 ? ultsInLostFights / effFightsLost : 0, - wastedUltimates: totalWastedUlts, - totalUltsUsedInFights: totalOurUltsInFights, - fightsWon: effFightsWon, - fightsLost: nonDryFights - effFightsWon, - dryFights, - dryFightWins, - dryFightWinrate: dryFights > 0 ? (dryFightWins / dryFights) * 100 : 0, - dryFightReversals, - dryFightReversalRate: - dryFights > 0 ? (dryFightReversals / dryFights) * 100 : 0, - nonDryFights, - nonDryFightReversals, - nonDryFightReversalRate: - nonDryFights > 0 ? (nonDryFightReversals / nonDryFights) * 100 : 0, - }; - - return { - ourUltsUsed, - opponentUltsUsed, - ultsByRole, - topUltUser, - avgChargeTime, - avgHoldTime, - playerComparisons, - ourFightInitiations, - opponentFightInitiations, - fightsWithUlts, - ourTopFightInitiator, - opponentTopFightInitiator, - ultEfficiency, - }; -} - -function heroSubroles(hero: string): SubroleName[] { - const result: SubroleName[] = []; - for (const subrole of SUBROLE_ORDER) { - if ((subroleHeroMapping[subrole] as string[]).includes(hero)) { - result.push(subrole); - } - } - return result; -} - -export type PlayerUltSummary = { - heroCountMap: Map; - totalCount: number; -}; - -function primaryHeroForPlayer(entry: PlayerUltSummary): string { - let bestHero = ""; - let bestCount = 0; - for (const [hero, count] of entry.heroCountMap) { - if (count > bestCount) { - bestCount = count; - bestHero = hero; - } - } - return bestHero; -} - -export type SubroleCandidate = { - playerName: string; - primaryHero: string; - possibleSubroles: SubroleName[]; - ultCount: number; -}; - -/** - * Assigns each player to a unique subrole slot. Players whose hero fits - * fewer subroles are assigned first so they don't lose their only option - * to a more flexible player. This handles overlap (e.g. Tracer appearing - * in both HitscanDamage and FlexDamage). - */ -export function assignPlayersToSubroles( - counts: Map -): Map { - const candidates: SubroleCandidate[] = []; - for (const [name, entry] of counts) { - const hero = primaryHeroForPlayer(entry); - const possible = heroSubroles(hero); - if (possible.length > 0) { - candidates.push({ - playerName: name, - primaryHero: hero, - possibleSubroles: possible, - ultCount: entry.totalCount, - }); - } - } - - candidates.sort( - (a, b) => - a.possibleSubroles.length - b.possibleSubroles.length || - b.ultCount - a.ultCount - ); - - const assigned = new Map(); - const usedPlayers = new Set(); - - for (const candidate of candidates) { - if (usedPlayers.has(candidate.playerName)) continue; - for (const subrole of candidate.possibleSubroles) { - if (!assigned.has(subrole)) { - assigned.set(subrole, candidate); - usedPlayers.add(candidate.playerName); - break; - } - } - } - - // Second pass: try to place any remaining unassigned players by checking - // if the current occupant of a slot could move to an alternative slot. - for (const candidate of candidates) { - if (usedPlayers.has(candidate.playerName)) continue; - for (const subrole of candidate.possibleSubroles) { - const occupant = assigned.get(subrole); - if (!occupant) { - assigned.set(subrole, candidate); - usedPlayers.add(candidate.playerName); - break; - } - const alternativeForOccupant = occupant.possibleSubroles.find( - (alt) => alt !== subrole && !assigned.has(alt) - ); - if (alternativeForOccupant) { - assigned.set(alternativeForOccupant, occupant); - assigned.set(subrole, candidate); - usedPlayers.add(candidate.playerName); - break; - } - } - } - - return assigned; -} - -export function buildPlayerUltComparisons( - ourPlayerUltCounts: Map, - oppPlayerUltCounts: Map -): PlayerUltComparison[] { - const ourBySubrole = assignPlayersToSubroles(ourPlayerUltCounts); - const oppBySubrole = assignPlayersToSubroles(oppPlayerUltCounts); - - const comparisons: PlayerUltComparison[] = []; - for (const subrole of SUBROLE_ORDER) { - const ours = ourBySubrole.get(subrole); - const theirs = oppBySubrole.get(subrole); - if (!ours && !theirs) continue; - - comparisons.push({ - subrole: SUBROLE_DISPLAY_NAMES[subrole], - ourPlayerName: ours?.playerName ?? "", - ourHero: ours?.primaryHero ?? "", - ourUltCount: ours?.ultCount ?? 0, - opponentPlayerName: theirs?.playerName ?? "", - opponentHero: theirs?.primaryHero ?? "", - opponentUltCount: theirs?.ultCount ?? 0, - }); - } - - return comparisons; -} - -function computeScrimSwapAnalysis( - allHeroSwaps: SwapRecord[], - allRoundStarts: { match_time: number; MapDataId: number | null }[], - allMatchEnds: { match_time: number; MapDataId: number | null }[], - mapIds: number[], - ourTeamNameByMap: Map, - mapResults: MapResult[] -): ScrimSwapAnalysis { - if (allHeroSwaps.length === 0) return emptySwapAnalysis(); - - const roundStartsByMap = new Map(); - for (const rs of allRoundStarts) { - if (!rs.MapDataId) continue; - const existing = roundStartsByMap.get(rs.MapDataId) ?? []; - existing.push(rs.match_time); - roundStartsByMap.set(rs.MapDataId, existing); - } - - const ourSwapsByMap = new Map(); - const opponentSwapsByMap = new Map(); - - for (const swap of allHeroSwaps) { - if (!swap.MapDataId) continue; - const ourTeam = ourTeamNameByMap.get(swap.MapDataId); - if (!ourTeam) continue; - - if (swap.player_team === ourTeam) { - const existing = ourSwapsByMap.get(swap.MapDataId) ?? []; - existing.push(swap); - ourSwapsByMap.set(swap.MapDataId, existing); - } else { - const existing = opponentSwapsByMap.get(swap.MapDataId) ?? []; - existing.push(swap); - opponentSwapsByMap.set(swap.MapDataId, existing); - } - } - - const filteredOurByMap = new Map(); - for (const [mapId, swaps] of ourSwapsByMap) { - const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; - const filtered = filterUtilityRoundStartSwaps(swaps, rsTimesForMap); - if (filtered.length > 0) filteredOurByMap.set(mapId, filtered); - } - - const filteredOppByMap = new Map(); - for (const [mapId, swaps] of opponentSwapsByMap) { - const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; - const filtered = filterUtilityRoundStartSwaps(swaps, rsTimesForMap); - if (filtered.length > 0) filteredOppByMap.set(mapId, filtered); - } - - const allOurSwaps: SwapRecord[] = []; - for (const swaps of filteredOurByMap.values()) allOurSwaps.push(...swaps); - const allOppSwaps: SwapRecord[] = []; - for (const swaps of filteredOppByMap.values()) allOppSwaps.push(...swaps); - - const mapCount = mapIds.length; - const ourSwaps = allOurSwaps.length; - const opponentSwaps = allOppSwaps.length; - const mapsWithOurSwaps = filteredOurByMap.size; - const mapsWithoutOurSwaps = mapCount - mapsWithOurSwaps; - - const mapResultLookup = new Map(); - for (const mr of mapResults) mapResultLookup.set(mr.mapId, mr); - - let noSwapWins = 0; - let noSwapLosses = 0; - let swapWins = 0; - let swapLosses = 0; - - for (const mapId of mapIds) { - const result = mapResultLookup.get(mapId); - if (!result || result.winner === "draw") continue; - const hasSwaps = filteredOurByMap.has(mapId); - const isWin = result.winner === "our_team"; - - if (hasSwaps) { - if (isWin) swapWins++; - else swapLosses++; - } else { - if (isWin) noSwapWins++; - else noSwapLosses++; - } - } - - const noSwapTotal = noSwapWins + noSwapLosses; - const swapTotal = swapWins + swapLosses; - - let totalHeroTimeBeforeSwap = 0; - for (const swap of allOurSwaps) - totalHeroTimeBeforeSwap += swap.hero_time_played; - - function findTopSwap(swaps: SwapRecord[]) { - const counts = new Map< - string, - { from: string; to: string; count: number } - >(); - for (const swap of swaps) { - const key = `${swap.previous_hero}->${swap.player_hero}`; - const existing = counts.get(key); - if (existing) existing.count++; - else - counts.set(key, { - from: swap.previous_hero, - to: swap.player_hero, - count: 1, - }); - } - let best: { from: string; to: string; count: number } | null = null; - for (const entry of counts.values()) { - if (!best || entry.count > best.count) best = entry; - } - return best; - } - - const playerSwapCounts = new Map< - string, - { count: number; maps: Set } - >(); - for (const swap of allOurSwaps) { - if (!swap.MapDataId) continue; - const entry = playerSwapCounts.get(swap.player_name) ?? { - count: 0, - maps: new Set(), - }; - entry.count++; - entry.maps.add(swap.MapDataId); - playerSwapCounts.set(swap.player_name, entry); - } - - let topSwapper: ScrimSwapAnalysis["topSwapper"] = null; - for (const [playerName, entry] of playerSwapCounts) { - if (!topSwapper || entry.count > topSwapper.count) { - topSwapper = { - playerName, - count: entry.count, - mapsCount: entry.maps.size, - }; - } - } - - const swapCountByMap = new Map(); - for (const mapId of mapIds) { - swapCountByMap.set(mapId, filteredOurByMap.get(mapId)?.length ?? 0); - } - - const countBuckets: { label: string; min: number; max: number }[] = [ - { label: "0 swaps", min: 0, max: 0 }, - { label: "1 swap", min: 1, max: 1 }, - { label: "2 swaps", min: 2, max: 2 }, - { label: "3+ swaps", min: 3, max: Infinity }, - ]; - - const winrateBySwapCount: SwapWinrateBucket[] = countBuckets.map((bucket) => { - let wins = 0; - let losses = 0; - - for (const mapId of mapIds) { - const result = mapResultLookup.get(mapId); - if (!result || result.winner === "draw") continue; - const count = swapCountByMap.get(mapId) ?? 0; - if (count >= bucket.min && count <= bucket.max) { - if (result.winner === "our_team") wins++; - else losses++; - } - } - - const total = wins + losses; - return { - label: bucket.label, - wins, - losses, - winrate: total > 0 ? (wins / total) * 100 : 0, - totalMaps: total, - }; - }); - - const matchEndTimeMap = new Map(); - for (const me of allMatchEnds) { - if (me.MapDataId) { - const existing = matchEndTimeMap.get(me.MapDataId); - if (!existing || me.match_time > existing) { - matchEndTimeMap.set(me.MapDataId, me.match_time); - } - } - } - - const timingLabels = ["Early (0-33%)", "Mid (33-66%)", "Late (66-100%)"]; - const timingMapSets: { wins: Set; losses: Set }[] = [ - { wins: new Set(), losses: new Set() }, - { wins: new Set(), losses: new Set() }, - { wins: new Set(), losses: new Set() }, - ]; - - for (const swap of allOurSwaps) { - if (!swap.MapDataId) continue; - const totalTime = matchEndTimeMap.get(swap.MapDataId); - if (!totalTime || totalTime <= 0) continue; - - const pct = (swap.match_time / totalTime) * 100; - let timingIndex: number; - if (pct < 33.33) timingIndex = 0; - else if (pct < 66.67) timingIndex = 1; - else timingIndex = 2; - - const result = mapResultLookup.get(swap.MapDataId); - if (!result || result.winner === "draw") continue; - - if (result.winner === "our_team") { - timingMapSets[timingIndex].wins.add(swap.MapDataId); - } else { - timingMapSets[timingIndex].losses.add(swap.MapDataId); - } - } - - const timingOutcomes: SwapTimingOutcome[] = timingLabels.map((label, i) => { - const wins = timingMapSets[i].wins.size; - const losses = timingMapSets[i].losses.size; - const total = wins + losses; - return { - label, - wins, - losses, - winrate: total > 0 ? (wins / total) * 100 : 0, - totalMaps: total, - }; - }); - - return { - ourSwaps, - opponentSwaps, - ourSwapsPerMap: mapCount > 0 ? ourSwaps / mapCount : 0, - opponentSwapsPerMap: mapCount > 0 ? opponentSwaps / mapCount : 0, - mapsWithOurSwaps, - mapsWithoutOurSwaps, - noSwapWinrate: noSwapTotal > 0 ? (noSwapWins / noSwapTotal) * 100 : 0, - noSwapWins, - noSwapLosses, - swapWinrate: swapTotal > 0 ? (swapWins / swapTotal) * 100 : 0, - swapWins, - swapLosses, - avgHeroTimeBeforeSwap: - ourSwaps > 0 ? totalHeroTimeBeforeSwap / ourSwaps : 0, - ourTopSwap: findTopSwap(allOurSwaps), - opponentTopSwap: findTopSwap(allOppSwaps), - topSwapper, - winrateBySwapCount, - timingOutcomes, - }; -} - -async function getScrimOverviewFn( - scrimId: number, - teamId: number -): Promise { - const maps = await prisma.map.findMany({ - where: { scrimId }, - orderBy: { id: "asc" }, - select: { id: true, name: true, mapData: { select: { id: true } } }, - }); - - if (maps.length === 0) return emptyOverviewData(); - - const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); - - if (mapDataIds.length === 0) return emptyOverviewData(); - - // Get team roster and unique players in this scrim in parallel - const [teamRoster, scrimPlayerNames] = await Promise.all([ - getTeamRoster(teamId), - prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { player_name: true }, - distinct: ["player_name"], - }), - ]); - - const teamRosterSet = new Set(teamRoster); - const scrimPlayers = scrimPlayerNames - .map((p) => p.player_name) - .filter((name) => teamRosterSet.has(name)); - - if (scrimPlayers.length === 0) return emptyOverviewData(); - - const [ - finalRoundStats, - calculatedStats, - matchStarts, - finalRounds, - captures, - payloadProgressRows, - pointProgressRows, - allKills, - allRezzes, - allUltimates, - allHeroSwaps, - allRoundStarts, - allMatchEnds, - ] = await Promise.all([ - prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "MapDataId" IN (${Prisma.join(mapDataIds)}) - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."MapDataId" IN (${Prisma.join(mapDataIds)}) - AND ps."player_name" IN (${Prisma.join(scrimPlayers)}) - `.then((rows) => removeDuplicateRows(rows)), - prisma.calculatedStat.findMany({ - where: { - MapDataId: { in: mapDataIds }, - playerName: { in: scrimPlayers }, - }, - }), - prisma.matchStart.findMany({ where: { MapDataId: { in: mapDataIds } } }), - prisma.roundEnd.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: { round_number: "desc" }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.kill.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.mercyRez.findMany({ - where: { MapDataId: { in: mapDataIds } }, - }), - prisma.ultimateStart.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.heroSwap.findMany({ - where: { - MapDataId: { in: mapDataIds }, - match_time: { not: 0 }, - }, - select: { - id: true, - match_time: true, - player_team: true, - player_name: true, - player_hero: true, - previous_hero: true, - hero_time_played: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.roundStart.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { match_time: true, MapDataId: true }, - }), - prisma.matchEnd.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { match_time: true, MapDataId: true }, - }), - ]); - - if (finalRoundStats.length === 0) return emptyOverviewData(); - - // Build lookup maps for matchStarts, finalRounds - const matchStartByMapId = new Map(); - for (const ms of matchStarts) { - if (ms.MapDataId) matchStartByMapId.set(ms.MapDataId, ms); - } - - const finalRoundByMapId = new Map(); - for (const round of finalRounds) { - if (round.MapDataId && !finalRoundByMapId.has(round.MapDataId)) { - finalRoundByMapId.set(round.MapDataId, round); - } - } - - const capturesByMapAndTeam = new Map< - number, - Map - >(); - for (const capture of captures) { - if (!capture.MapDataId) continue; - if (!capturesByMapAndTeam.has(capture.MapDataId)) { - capturesByMapAndTeam.set( - capture.MapDataId, - new Map() - ); - } - const mapCaptures = capturesByMapAndTeam.get(capture.MapDataId)!; - if (!mapCaptures.has(capture.capturing_team)) { - mapCaptures.set(capture.capturing_team, []); - } - mapCaptures.get(capture.capturing_team)!.push(capture); - } - - const payloadProgressByMapAndTeam = new Map< - number, - Map - >(); - for (const progressRow of payloadProgressRows) { - if (!progressRow.MapDataId) continue; - if (!payloadProgressByMapAndTeam.has(progressRow.MapDataId)) { - payloadProgressByMapAndTeam.set( - progressRow.MapDataId, - new Map() - ); - } - const mapProgressRows = payloadProgressByMapAndTeam.get( - progressRow.MapDataId - )!; - if (!mapProgressRows.has(progressRow.capturing_team)) { - mapProgressRows.set(progressRow.capturing_team, []); - } - mapProgressRows.get(progressRow.capturing_team)!.push(progressRow); - } - - const pointProgressByMapAndTeam = new Map< - number, - Map - >(); - for (const progressRow of pointProgressRows) { - if (!progressRow.MapDataId) continue; - if (!pointProgressByMapAndTeam.has(progressRow.MapDataId)) { - pointProgressByMapAndTeam.set( - progressRow.MapDataId, - new Map() - ); - } - const mapProgressRows = pointProgressByMapAndTeam.get( - progressRow.MapDataId - )!; - if (!mapProgressRows.has(progressRow.capturing_team)) { - mapProgressRows.set(progressRow.capturing_team, []); - } - mapProgressRows.get(progressRow.capturing_team)!.push(progressRow); - } - - const killsByMap = new Map(); - for (const kill of allKills) { - if (!kill.MapDataId) continue; - if (!killsByMap.has(kill.MapDataId)) { - killsByMap.set(kill.MapDataId, []); - } - killsByMap.get(kill.MapDataId)!.push(kill); - } - for (const rez of allRezzes) { - if (!rez.MapDataId) continue; - if (!killsByMap.has(rez.MapDataId)) { - killsByMap.set(rez.MapDataId, []); - } - killsByMap.get(rez.MapDataId)!.push(mercyRezToKillEvent(rez)); - } - - const fightsByMap = new Map(); - for (const [mapId, events] of killsByMap.entries()) { - events.sort((a, b) => a.match_time - b.match_time); - fightsByMap.set(mapId, groupEventsIntoFights(events)); - } - - // Build a separate fight map that includes ults in the event stream, - // so fight windows expand to cover ultimates used outside kill activity. - // This ensures the timing chart counts all ults, not just those near kills. - // Build a map from Map.id to MapData.id for lookups - const mapIdToMapDataId = new Map(); - for (const map of maps) { - if (map.mapData[0]) { - mapIdToMapDataId.set(map.id, map.mapData[0].id); - } - } - - const fightsByMapWithUlts = new Map(); - for (const mdId of mapDataIds) { - const kills = killsByMap.get(mdId) ?? []; - const mapUlts = allUltimates.filter((u) => u.MapDataId === mdId); - const merged: Kill[] = [...kills, ...mapUlts.map(ultimateStartToKillEvent)]; - merged.sort((a, b) => a.match_time - b.match_time); - fightsByMapWithUlts.set(mdId, groupEventsIntoFights(merged)); - } - - type MapFirstDeathLookup = { - fightCount: number; - overallFirstDeaths: Map; - teamFirstDeaths: Map>; - }; - const firstDeathLookup = new Map(); - for (const [mapId, fights] of fightsByMap.entries()) { - const overallFirstDeaths = new Map(); - const teamFirstDeaths = new Map>(); - - for (const fight of fights) { - const overallVictim = fight.kills[0]?.victim_name; - if (overallVictim) { - overallFirstDeaths.set( - overallVictim, - (overallFirstDeaths.get(overallVictim) ?? 0) + 1 - ); - } - - const teamsSeen = new Set(); - for (const kill of fight.kills) { - if (kill.event_type !== "kill" || teamsSeen.has(kill.victim_team)) - continue; - teamsSeen.add(kill.victim_team); - if (!teamFirstDeaths.has(kill.victim_team)) { - teamFirstDeaths.set(kill.victim_team, new Map()); - } - const playerCounts = teamFirstDeaths.get(kill.victim_team)!; - playerCounts.set( - kill.victim_name, - (playerCounts.get(kill.victim_name) ?? 0) + 1 - ); - } - } - - firstDeathLookup.set(mapId, { - fightCount: fights.length, - overallFirstDeaths, - teamFirstDeaths, - }); - } - - const ourTeamNameByMap = getMapTeamNames(finalRoundStats); - - const fightAnalysis = computeScrimFightAnalysis( - mapDataIds, - killsByMap, - allUltimates, - ourTeamNameByMap - ); - - const ultAnalysis = computeScrimUltAnalysis( - allUltimates, - fightsByMapWithUlts, - ourTeamNameByMap, - calculatedStats, - scrimPlayers - ); - - // Determine W/L/D for each map - const mapResults: MapResult[] = []; - let wins = 0; - let losses = 0; - let draws = 0; - - for (const map of maps) { - const mdId = mapIdToMapDataId.get(map.id); - const matchStart = mdId ? (matchStartByMapId.get(mdId) ?? null) : null; - const finalRound = mdId ? (finalRoundByMapId.get(mdId) ?? null) : null; - const mapCaptures = mdId ? capturesByMapAndTeam.get(mdId) : undefined; - const team1Caps = matchStart?.team_1_name - ? (mapCaptures?.get(matchStart.team_1_name) ?? []) - : []; - const team2Caps = matchStart?.team_2_name - ? (mapCaptures?.get(matchStart.team_2_name) ?? []) - : []; - const mapPayloadProgress = mdId - ? payloadProgressByMapAndTeam.get(mdId) - : undefined; - const team1PayloadProgress = matchStart?.team_1_name - ? (mapPayloadProgress?.get(matchStart.team_1_name) ?? []) - : []; - const team2PayloadProgress = matchStart?.team_2_name - ? (mapPayloadProgress?.get(matchStart.team_2_name) ?? []) - : []; - const mapPointProgress = mdId - ? pointProgressByMapAndTeam.get(mdId) - : undefined; - const team1PointProgress = matchStart?.team_1_name - ? (mapPointProgress?.get(matchStart.team_1_name) ?? []) - : []; - const team2PointProgress = matchStart?.team_2_name - ? (mapPointProgress?.get(matchStart.team_2_name) ?? []) - : []; - - const winnerName = calculateWinner({ - matchDetails: matchStart, - finalRound, - team1Captures: team1Caps, - team2Captures: team2Caps, - team1PayloadProgress, - team2PayloadProgress, - team1PointProgress, - team2PointProgress, - }); - - const ourTeamName = mdId ? (ourTeamNameByMap.get(mdId) ?? null) : null; - let winner: MapResult["winner"]; - - if (winnerName === "N/A" || !ourTeamName) { - winner = "draw"; - draws++; - } else if (winnerName === ourTeamName) { - winner = "our_team"; - wins++; - } else { - winner = "opponent"; - losses++; - } - - mapResults.push({ mapId: map.id, mapName: map.name, winner }); - } - - const playerRowsMap = new Map(); - for (const row of finalRoundStats) { - if (!playerRowsMap.has(row.player_name)) { - playerRowsMap.set(row.player_name, []); - } - playerRowsMap.get(row.player_name)!.push(row); - } - - const playerCalculatedMap = new Map(); - for (const row of calculatedStats) { - if (!playerCalculatedMap.has(row.playerName)) { - playerCalculatedMap.set(row.playerName, []); - } - playerCalculatedMap.get(row.playerName)!.push(row); - } - - const basePlayers: PlayerScrimPerformance[] = []; - for (const playerName of scrimPlayers) { - const playerRows = playerRowsMap.get(playerName) ?? []; - if (playerRows.length === 0) continue; - const playerCalcRows = playerCalculatedMap.get(playerName) ?? []; - - const rowsByMap = new Map(); - for (const row of playerRows) { - if (!row.MapDataId) continue; - if (!rowsByMap.has(row.MapDataId)) { - rowsByMap.set(row.MapDataId, []); - } - rowsByMap.get(row.MapDataId)!.push(row); - } - - const calcByMap = new Map(); - for (const row of playerCalcRows) { - if (!calcByMap.has(row.MapDataId)) { - calcByMap.set(row.MapDataId, []); - } - calcByMap.get(row.MapDataId)!.push(row); - } - - const playerTeam = playerRows[0].player_team; - - const perMapStats: PlayerStat[] = []; - const perMapCalculatedStats: CalculatedStat[][] = []; - const perMapPerformance: PlayerMapPerformance[] = []; - let totalFights = 0; - let totalFirstDeaths = 0; - let totalTeamFirstDeaths = 0; - - for (let i = 0; i < maps.length; i++) { - const mdId = mapIdToMapDataId.get(maps[i].id); - if (!mdId) continue; - const rows = rowsByMap.get(mdId) ?? []; - if (rows.length === 0) continue; - const aggregatedRow = aggregateRowsToPlayerStat(rows); - if (!aggregatedRow) continue; - perMapStats.push(aggregatedRow); - perMapCalculatedStats.push(calcByMap.get(mdId) ?? []); - - const fdLookup = firstDeathLookup.get(mdId); - const mapFightCount = fdLookup?.fightCount ?? 0; - const mapFirstDeaths = fdLookup?.overallFirstDeaths.get(playerName) ?? 0; - const mapTeamFirstDeaths = - fdLookup?.teamFirstDeaths.get(playerTeam)?.get(playerName) ?? 0; - - totalFights += mapFightCount; - totalFirstDeaths += mapFirstDeaths; - totalTeamFirstDeaths += mapTeamFirstDeaths; - - const time = aggregatedRow.hero_time_played; - perMapPerformance.push({ - mapName: maps[i].name, - mapIndex: i, - kdRatio: - aggregatedRow.deaths > 0 - ? aggregatedRow.eliminations / aggregatedRow.deaths - : aggregatedRow.eliminations, - eliminationsPer10: - time > 0 ? (aggregatedRow.eliminations / time) * 600 : 0, - heroDamagePer10: - time > 0 ? (aggregatedRow.hero_damage_dealt / time) * 600 : 0, - healingDealtPer10: - time > 0 ? (aggregatedRow.healing_dealt / time) * 600 : 0, - firstDeathRate: - mapFightCount > 0 ? (mapFirstDeaths / mapFightCount) * 100 : 0, - teamFirstDeathRate: - mapFightCount > 0 ? (mapTeamFirstDeaths / mapFightCount) * 100 : 0, - }); - } - - const aggregated = aggregatePlayerStats( - playerRows, - playerCalcRows, - perMapStats, - perMapCalculatedStats - ); - - const heroTimeMap = new Map(); - for (const row of playerRows) { - heroTimeMap.set( - row.player_hero, - (heroTimeMap.get(row.player_hero) ?? 0) + row.hero_time_played - ); - } - const heroes = Array.from(heroTimeMap.keys()) as HeroName[]; - if (heroes.length === 0) continue; - - let primaryHero: HeroName = heroes[0] ?? "Ana"; - let maxTime = -1; - for (const [hero, time] of heroTimeMap.entries()) { - if (time > maxTime) { - maxTime = time; - primaryHero = hero as HeroName; - } - } - - const trendData = - perMapStats.length >= 3 - ? calculateTrends(perMapStats, perMapCalculatedStats) - : undefined; - const trend = determineTrend(trendData); - const rowId = playerRows.reduce( - (lowest, row) => (row.id < lowest ? row.id : lowest), - playerRows[0].id - ); - - const kdRatio = - aggregated.deaths > 0 - ? aggregated.eliminations / aggregated.deaths - : aggregated.eliminations; - - basePlayers.push({ - playerKey: `${playerName}:${rowId}`, - playerName, - primaryHero, - heroes, - mapsPlayed: perMapStats.length, - eliminations: aggregated.eliminations, - deaths: aggregated.deaths, - heroDamageDealt: aggregated.heroDamageDealt, - healingDealt: aggregated.healingDealt, - heroTimePlayed: aggregated.heroTimePlayed, - kdRatio, - eliminationsPer10: aggregated.eliminationsPer10, - deathsPer10: aggregated.deathsPer10, - heroDamagePer10: aggregated.heroDamagePer10, - healingDealtPer10: aggregated.healingDealtPer10, - firstDeathCount: totalFirstDeaths, - firstDeathRate: - totalFights > 0 ? (totalFirstDeaths / totalFights) * 100 : 0, - teamFirstDeathCount: totalTeamFirstDeaths, - teamFirstDeathRate: - totalFights > 0 ? (totalTeamFirstDeaths / totalFights) * 100 : 0, - perMapPerformance, - zScores: {}, - outliers: [], - trend, - trendData, - } satisfies PlayerScrimPerformance); - } - - const uniqueHeroes = new Set(); - for (const player of basePlayers) { - if (player.heroTimePlayed >= 300) { - uniqueHeroes.add(player.primaryHero); - } - } - - const baselineMap = await getBatchedStatDistributionBaselines( - Array.from(uniqueHeroes) - ); - - const teamPlayers: PlayerScrimPerformance[] = basePlayers.map((player) => { - if (player.heroTimePlayed < 300) return player; - const zScores: Partial> = {}; - const outliers: ScrimOutlier[] = []; - - const comparisonStats = statsForHeroRole( - player.primaryHero, - player.heroDamageDealt, - player.healingDealt, - 0, - player.deaths, - player.eliminations - ); - - for (const statDef of comparisonStats) { - const baseline = baselineMap.get( - createBaselineKey(player.primaryHero, statDef.stat) - ); - if (!baseline) continue; - - const per10Value = (statDef.value / player.heroTimePlayed) * 600; - const zScore = calculateZScoreFromBaseline({ - baseline, - stat: statDef.stat, - per10Value, - }); - if (zScore === null) continue; - const percentile = Math.round(normalCdf(zScore) * 1000) / 10; - - zScores[statDef.stat] = zScore; - if (Math.abs(zScore) >= 2) { - outliers.push({ - stat: statDef.stat, - zScore, - percentile, - direction: zScore > 0 ? "high" : "low", - label: statLabelFor(statDef.stat), - }); - } - } - - outliers.sort((a, b) => Math.abs(b.zScore) - Math.abs(a.zScore)); - return { - ...player, - zScores, - outliers, - }; - }); - - // Sort by maps played desc, then alphabetically - teamPlayers.sort( - (a, b) => - b.mapsPlayed - a.mapsPlayed || a.playerName.localeCompare(b.playerName) - ); - - // Team-wide totals - const teamTotals = teamPlayers.reduce( - (acc, p) => ({ - eliminations: acc.eliminations + p.eliminations, - deaths: acc.deaths + p.deaths, - heroDamage: acc.heroDamage + p.heroDamageDealt, - healing: acc.healing + p.healingDealt, - kdRatio: 0, // computed below - }), - { eliminations: 0, deaths: 0, heroDamage: 0, healing: 0, kdRatio: 0 } - ); - teamTotals.kdRatio = - teamTotals.deaths > 0 - ? teamTotals.eliminations / teamTotals.deaths - : teamTotals.eliminations; - - const insights = generateInsights(teamPlayers); - - const firstMapDataId = mapDataIds[0]; - const firstOurTeamName = ourTeamNameByMap.get(firstMapDataId) ?? ""; - const firstMatchStart = matchStartByMapId.get(firstMapDataId); - const opponentTeamName = - firstMatchStart?.team_1_name === firstOurTeamName - ? (firstMatchStart?.team_2_name ?? "Opponent") - : (firstMatchStart?.team_1_name ?? "Opponent"); - - const swapAnalysis = computeScrimSwapAnalysis( - allHeroSwaps as SwapRecord[], - allRoundStarts, - allMatchEnds, - mapDataIds, - ourTeamNameByMap, - mapResults - ); - - const abilityTimingAnalysis = await getScrimAbilityTiming(scrimId, teamId); - - return { - mapCount: maps.length, - wins, - losses, - draws, - ourTeamName: firstOurTeamName, - opponentTeamName, - mapResults, - teamPlayers, - insights, - teamTotals, - fightAnalysis, - ultAnalysis, - swapAnalysis, - abilityTimingAnalysis, - }; -} - -export const getScrimOverview = cache(getScrimOverviewFn); diff --git a/src/data/targets-dto.ts b/src/data/targets-dto.ts deleted file mode 100644 index ec1333247..000000000 --- a/src/data/targets-dto.ts +++ /dev/null @@ -1,223 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { TargetStatKey } from "@/lib/target-stats"; -import { removeDuplicateRows, toMins } from "@/lib/utils"; -import type { PlayerStat, PlayerTarget } from "@prisma/client"; -import { Prisma } from "@prisma/client"; -import { cache } from "react"; - -export async function getPlayerTargets(teamId: number, playerName: string) { - return prisma.playerTarget.findMany({ - where: { teamId, playerName: { equals: playerName, mode: "insensitive" } }, - include: { creator: { select: { name: true, email: true } } }, - orderBy: { createdAt: "desc" }, - }); -} - -export async function getTeamTargets(teamId: number) { - const targets = await prisma.playerTarget.findMany({ - where: { teamId }, - include: { creator: { select: { name: true, email: true } } }, - orderBy: { createdAt: "desc" }, - }); - - const grouped: Record = {}; - for (const target of targets) { - if (!grouped[target.playerName]) { - grouped[target.playerName] = []; - } - grouped[target.playerName].push(target); - } - return grouped; -} - -export type ScrimStatPoint = { - scrimId: number; - scrimDate: string; - scrimName: string; - stats: Record; -}; - -async function getRecentScrimStatsFn( - playerName: string, - teamId: number, - scrimCount: number -): Promise { - // 1. Find the last N scrims for the team - const recentScrims = await prisma.scrim.findMany({ - where: { teamId }, - orderBy: { date: "desc" }, - take: scrimCount, - select: { - id: true, - date: true, - name: true, - maps: { - select: { - id: true, - mapData: { select: { id: true } }, - }, - }, - }, - }); - - if (recentScrims.length === 0) return []; - - // 2. Get MapData IDs from those scrims - const mapIds = recentScrims.flatMap((s) => - s.maps.flatMap((m) => m.mapData.map((md) => md.id)) - ); - if (mapIds.length === 0) return []; - - // 3. Get final-round PlayerStat rows for this player across those maps - const stats = removeDuplicateRows( - await prisma.$queryRaw` - WITH maxTime AS ( - SELECT - MAX("match_time") AS max_time, - "MapDataId" - FROM - "PlayerStat" - WHERE - "MapDataId" IN (${Prisma.join(mapIds)}) - GROUP BY - "MapDataId" - ) - SELECT - ps.* - FROM - "PlayerStat" ps - INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" - WHERE - ps."MapDataId" IN (${Prisma.join(mapIds)}) - AND ps."player_name" ILIKE ${playerName}` - ); - - // 4. Aggregate per scrim: sum stat values, compute per-10 - const results: ScrimStatPoint[] = []; - for (const scrim of recentScrims) { - const scrimMapIds = new Set( - scrim.maps.flatMap((m) => m.mapData.map((md) => md.id)) - ); - const scrimStats = stats.filter((s) => scrimMapIds.has(s.MapDataId!)); - - if (scrimStats.length === 0) continue; - - const totalTime = scrimStats.reduce( - (sum, s) => sum + s.hero_time_played, - 0 - ); - const timeMins = toMins(totalTime); - if (timeMins <= 0) continue; - - const statKeys: TargetStatKey[] = [ - "eliminations", - "deaths", - "hero_damage_dealt", - "damage_taken", - "damage_blocked", - "final_blows", - "healing_dealt", - "ultimates_earned", - ]; - - const per10Stats: Record = {}; - for (const key of statKeys) { - const total = scrimStats.reduce( - (sum, s) => sum + (Number(s[key]) || 0), - 0 - ); - per10Stats[key] = (total / timeMins) * 10; - } - - results.push({ - scrimId: scrim.id, - scrimDate: scrim.date.toISOString(), - scrimName: scrim.name, - stats: per10Stats, - }); - } - - // Sort chronologically (oldest first) - return results.sort( - (a, b) => new Date(a.scrimDate).getTime() - new Date(b.scrimDate).getTime() - ); -} - -export const getRecentScrimStats = cache(getRecentScrimStatsFn); - -export type TargetProgress = { - target: PlayerTarget & { - creator: { name: string | null; email: string }; - }; - currentValue: number; - progressPercent: number; - trending: "toward" | "away" | "neutral"; -}; - -export function calculateTargetProgress( - target: PlayerTarget, - recentStats: ScrimStatPoint[] -): Omit { - if (recentStats.length === 0) { - return { - currentValue: target.baselineValue, - progressPercent: 0, - trending: "neutral", - }; - } - - // Calculate current average per-10 for the stat - const values = recentStats - .map((s) => s.stats[target.stat]) - .filter((v) => v !== undefined && isFinite(v)); - - if (values.length === 0) { - return { - currentValue: target.baselineValue, - progressPercent: 0, - trending: "neutral", - }; - } - - const currentValue = values.reduce((a, b) => a + b, 0) / values.length; - - // Calculate target value - const multiplier = - target.targetDirection === "increase" - ? 1 + target.targetPercent / 100 - : 1 - target.targetPercent / 100; - const targetValue = target.baselineValue * multiplier; - - // Calculate progress: how far from baseline toward target - const totalDistance = targetValue - target.baselineValue; - if (Math.abs(totalDistance) < 0.001) { - return { currentValue, progressPercent: 100, trending: "neutral" }; - } - - const currentDistance = currentValue - target.baselineValue; - const progressPercent = Math.min( - 100, - Math.max(0, (currentDistance / totalDistance) * 100) - ); - - // Determine trend from last few data points - let trending: "toward" | "away" | "neutral" = "neutral"; - if (values.length >= 2) { - const recentHalf = values.slice(Math.floor(values.length / 2)); - const earlierHalf = values.slice(0, Math.floor(values.length / 2)); - const recentAvg = recentHalf.reduce((a, b) => a + b, 0) / recentHalf.length; - const earlierAvg = - earlierHalf.reduce((a, b) => a + b, 0) / earlierHalf.length; - - const diff = recentAvg - earlierAvg; - if (target.targetDirection === "increase") { - trending = diff > 0 ? "toward" : diff < 0 ? "away" : "neutral"; - } else { - trending = diff < 0 ? "toward" : diff > 0 ? "away" : "neutral"; - } - } - - return { currentValue, progressPercent, trending }; -} diff --git a/src/data/team-ability-impact-dto.ts b/src/data/team-ability-impact-dto.ts deleted file mode 100644 index c1dc4c5c6..000000000 --- a/src/data/team-ability-impact-dto.ts +++ /dev/null @@ -1,441 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { heroAbilityMapping } from "@/types/heroes"; -import type { HeroName } from "@/types/heroes"; -import { cache } from "react"; -import type { ExtendedTeamData, TeamDateRange } from "./team-shared-core"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getExtendedTeamData } from "./team-shared-data"; - -export type AbilityScenarioStats = { - fights: number; - wins: number; - losses: number; - winrate: number; -}; - -export type AbilityImpactData = { - abilityName: string; - totalFightsAnalyzed: number; - scenarios: { - usedByUs: AbilityScenarioStats; - notUsedByUs: AbilityScenarioStats; - usedByEnemy: AbilityScenarioStats; - notUsedByEnemy: AbilityScenarioStats; - }; -}; - -export type HeroAbilityImpact = { - hero: string; - ability1: AbilityImpactData; - ability2: AbilityImpactData; -}; - -export type AbilityImpactAnalysis = { - byHero: Record; - availableHeroes: string[]; -}; - -function emptyScenario(): AbilityScenarioStats { - return { fights: 0, wins: 0, losses: 0, winrate: 0 }; -} - -function finalizeScenario(s: AbilityScenarioStats): AbilityScenarioStats { - return { - ...s, - winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0, - }; -} - -type FightEvent = { - event_type: string; - match_time: number; - attacker_team: string; - attacker_hero: string; - victim_team?: string; - victim_hero?: string; -}; - -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; - -type AbilityEvent = { - match_time: number; - player_team: string; - player_hero: string; - MapDataId: number | null; -}; - -/** Buffer in seconds before fight start to capture initiating abilities */ -const ABILITY_PRE_BUFFER = 5; -/** Buffer in seconds after fight end to capture cleanup abilities */ -const ABILITY_POST_BUFFER = 2; - -function processAbilityImpactAnalysis( - sharedData: ExtendedTeamData, - allAbility1Events: AbilityEvent[], - allAbility2Events: AbilityEvent[] -): AbilityImpactAnalysis { - const { teamRosterSet, mapDataIds, allPlayerStats, allKills, allRezzes } = - sharedData; - - if (mapDataIds.length === 0) { - return { byHero: {}, availableHeroes: [] }; - } - - // Build team name lookup for each map - const teamNameByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } - } - - // Group events by MapDataId - const killsByMap = new Map(); - const rezzesByMap = new Map(); - const ab1ByMap = new Map(); - const ab2ByMap = new Map(); - - for (const kill of allKills) { - if (kill.MapDataId) { - if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); - killsByMap.get(kill.MapDataId)!.push(kill); - } - } - - for (const rez of allRezzes) { - if (rez.MapDataId) { - if (!rezzesByMap.has(rez.MapDataId)) rezzesByMap.set(rez.MapDataId, []); - rezzesByMap.get(rez.MapDataId)!.push(rez); - } - } - - for (const ab of allAbility1Events) { - if (ab.MapDataId) { - if (!ab1ByMap.has(ab.MapDataId)) ab1ByMap.set(ab.MapDataId, []); - ab1ByMap.get(ab.MapDataId)!.push(ab); - } - } - - for (const ab of allAbility2Events) { - if (ab.MapDataId) { - if (!ab2ByMap.has(ab.MapDataId)) ab2ByMap.set(ab.MapDataId, []); - ab2ByMap.get(ab.MapDataId)!.push(ab); - } - } - - // Accumulate per-hero stats - type AbilitySlotAccum = { - usedByUs: AbilityScenarioStats; - notUsedByUs: AbilityScenarioStats; - usedByEnemy: AbilityScenarioStats; - notUsedByEnemy: AbilityScenarioStats; - totalFightsAnalyzed: number; - }; - - type HeroAccum = { - ability1: AbilitySlotAccum; - ability2: AbilitySlotAccum; - }; - - const heroStats = new Map(); - - function getOrCreateHero(hero: string): HeroAccum { - if (!heroStats.has(hero)) { - heroStats.set(hero, { - ability1: { - usedByUs: emptyScenario(), - notUsedByUs: emptyScenario(), - usedByEnemy: emptyScenario(), - notUsedByEnemy: emptyScenario(), - totalFightsAnalyzed: 0, - }, - ability2: { - usedByUs: emptyScenario(), - notUsedByUs: emptyScenario(), - usedByEnemy: emptyScenario(), - notUsedByEnemy: emptyScenario(), - totalFightsAnalyzed: 0, - }, - }); - } - return heroStats.get(hero)!; - } - - function incrementScenario(scenario: AbilityScenarioStats, won: boolean) { - scenario.fights++; - if (won) scenario.wins++; - else scenario.losses++; - } - - for (const mapDataId of mapDataIds) { - const ourTeamName = teamNameByMapId.get(mapDataId); - if (!ourTeamName) continue; - - const kills = killsByMap.get(mapDataId) ?? []; - const rezzes = rezzesByMap.get(mapDataId) ?? []; - const ab1s = ab1ByMap.get(mapDataId) ?? []; - const ab2s = ab2ByMap.get(mapDataId) ?? []; - - if (kills.length === 0 && rezzes.length === 0) continue; - - // Build fight events from kills and rezzes only - const fightEvents: FightEvent[] = [ - ...kills.map((k) => ({ - event_type: k.event_type, - match_time: k.match_time, - attacker_team: k.attacker_team, - attacker_hero: k.attacker_hero, - victim_team: k.victim_team, - victim_hero: k.victim_hero, - })), - ...rezzes.map((rez) => ({ - event_type: "mercy_rez" as const, - match_time: rez.match_time, - attacker_team: rez.resurrecter_team, - attacker_hero: rez.resurrecter_hero, - victim_team: rez.resurrectee_team, - victim_hero: undefined, - })), - ]; - - fightEvents.sort((a, b) => a.match_time - b.match_time); - - // Group into fights using 15s gap rule - const fights: Fight[] = []; - let currentFight: Fight | null = null; - - for (const event of fightEvents) { - if (!currentFight || event.match_time - currentFight.end > 15) { - currentFight = { - events: [event], - start: event.match_time, - end: event.match_time, - }; - fights.push(currentFight); - } else { - currentFight.events.push(event); - currentFight.end = event.match_time; - } - } - - // Analyze each fight - for (const fight of fights) { - // Determine winner by kill differential - let ourKills = 0; - let enemyKills = 0; - - for (const event of fight.events) { - if (event.event_type === "mercy_rez") { - if (event.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) ourKills++; - else enemyKills++; - } - } - - const won = ourKills > enemyKills; - - // Find heroes present in fight from kill events - const heroPresence = new Map< - string, - { ourTeam: boolean; enemyTeam: boolean } - >(); - - function markPresence(hero: string, team: string) { - if (!heroPresence.has(hero)) { - heroPresence.set(hero, { ourTeam: false, enemyTeam: false }); - } - const p = heroPresence.get(hero)!; - if (team === ourTeamName) p.ourTeam = true; - else p.enemyTeam = true; - } - - for (const event of fight.events) { - if (event.event_type === "kill" || event.event_type === "mercy_rez") { - markPresence(event.attacker_hero, event.attacker_team); - if (event.victim_hero && event.victim_team) { - markPresence(event.victim_hero, event.victim_team); - } - } - } - - // Find abilities used in fight window (with buffer) - const windowStart = fight.start - ABILITY_PRE_BUFFER; - const windowEnd = fight.end + ABILITY_POST_BUFFER; - - const fightAb1s = ab1s.filter( - (a) => a.match_time >= windowStart && a.match_time <= windowEnd - ); - const fightAb2s = ab2s.filter( - (a) => a.match_time >= windowStart && a.match_time <= windowEnd - ); - - // Also mark heroes from ability events as present - for (const ab of [...fightAb1s, ...fightAb2s]) { - markPresence(ab.player_hero, ab.player_team); - } - - // Build ability usage sets per team - const ourAb1Heroes = new Set(); - const ourAb2Heroes = new Set(); - const enemyAb1Heroes = new Set(); - const enemyAb2Heroes = new Set(); - - for (const ab of fightAb1s) { - if (ab.player_team === ourTeamName) ourAb1Heroes.add(ab.player_hero); - else enemyAb1Heroes.add(ab.player_hero); - } - - for (const ab of fightAb2s) { - if (ab.player_team === ourTeamName) ourAb2Heroes.add(ab.player_hero); - else enemyAb2Heroes.add(ab.player_hero); - } - - // Classify per hero - for (const [hero, presence] of heroPresence) { - const stats = getOrCreateHero(hero); - - if (presence.ourTeam) { - stats.ability1.totalFightsAnalyzed++; - if (ourAb1Heroes.has(hero)) { - incrementScenario(stats.ability1.usedByUs, won); - } else { - incrementScenario(stats.ability1.notUsedByUs, won); - } - - stats.ability2.totalFightsAnalyzed++; - if (ourAb2Heroes.has(hero)) { - incrementScenario(stats.ability2.usedByUs, won); - } else { - incrementScenario(stats.ability2.notUsedByUs, won); - } - } - - if (presence.enemyTeam) { - if (enemyAb1Heroes.has(hero)) { - incrementScenario(stats.ability1.usedByEnemy, won); - } else { - incrementScenario(stats.ability1.notUsedByEnemy, won); - } - - if (enemyAb2Heroes.has(hero)) { - incrementScenario(stats.ability2.usedByEnemy, won); - } else { - incrementScenario(stats.ability2.notUsedByEnemy, won); - } - } - } - } - } - - // Build result - const byHero: Record = {}; - - for (const [hero, stats] of heroStats) { - const totalFights = Math.max( - stats.ability1.totalFightsAnalyzed, - stats.ability2.totalFightsAnalyzed - ); - if (totalFights < 1) continue; - - const abilities = heroAbilityMapping[hero as HeroName]; - if (!abilities) continue; - - byHero[hero] = { - hero, - ability1: { - abilityName: abilities.ability1Name, - totalFightsAnalyzed: stats.ability1.totalFightsAnalyzed, - scenarios: { - usedByUs: finalizeScenario(stats.ability1.usedByUs), - notUsedByUs: finalizeScenario(stats.ability1.notUsedByUs), - usedByEnemy: finalizeScenario(stats.ability1.usedByEnemy), - notUsedByEnemy: finalizeScenario(stats.ability1.notUsedByEnemy), - }, - }, - ability2: { - abilityName: abilities.ability2Name, - totalFightsAnalyzed: stats.ability2.totalFightsAnalyzed, - scenarios: { - usedByUs: finalizeScenario(stats.ability2.usedByUs), - notUsedByUs: finalizeScenario(stats.ability2.notUsedByUs), - usedByEnemy: finalizeScenario(stats.ability2.usedByEnemy), - notUsedByEnemy: finalizeScenario(stats.ability2.notUsedByEnemy), - }, - }, - }; - } - - const availableHeroes = Object.values(byHero) - .sort((a, b) => { - const aTotal = Math.max( - a.ability1.totalFightsAnalyzed, - a.ability2.totalFightsAnalyzed - ); - const bTotal = Math.max( - b.ability1.totalFightsAnalyzed, - b.ability2.totalFightsAnalyzed - ); - return bTotal - aTotal; - }) - .map((h) => h.hero); - - return { byHero, availableHeroes }; -} - -async function getTeamAbilityImpactUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); - - if (sharedData.mapDataIds.length === 0) { - return { byHero: {}, availableHeroes: [] }; - } - - const [allAbility1Events, allAbility2Events] = await Promise.all([ - prisma.ability1Used.findMany({ - where: { MapDataId: { in: sharedData.mapDataIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.ability2Used.findMany({ - where: { MapDataId: { in: sharedData.mapDataIds } }, - select: { - match_time: true, - player_team: true, - player_hero: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - ]); - - return processAbilityImpactAnalysis( - sharedData, - allAbility1Events, - allAbility2Events - ); -} - -export const getTeamAbilityImpact = cache(getTeamAbilityImpactUncached); diff --git a/src/data/team-analytics-dto.tsx b/src/data/team-analytics-dto.tsx deleted file mode 100644 index 4d4097c26..000000000 --- a/src/data/team-analytics-dto.tsx +++ /dev/null @@ -1,581 +0,0 @@ -import "server-only"; - -import { determineRole } from "@/lib/player-table-data"; -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData, getTeamRoster } from "./team-shared-data"; - -export type HeroPickrate = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - playtime: number; - gamesPlayed: number; -}; - -export type PlayerHeroData = { - playerName: string; - heroes: HeroPickrate[]; - totalPlaytime: number; -}; - -export type HeroPickrateMatrix = { - players: PlayerHeroData[]; - allHeroes: HeroName[]; -}; - -export type HeroPickrateRawData = { - teamRoster: string[]; - mapDataRecords: { - id: number; - name: string | null; - scrimDate: Date; - }[]; - allPlayerStats: { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - }[]; -}; - -export type PlayerMapPerformance = { - playerName: string; - mapName: string; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; -}; - -export type PlayerMapPerformanceMatrix = { - players: string[]; - maps: string[]; - performance: PlayerMapPerformance[]; -}; - -// Deprecated - use getBaseTeamData from team-shared-data instead -async function getSharedAnalyticsDataUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - excludeClash: true, - dateRange, - }); - - if (sharedData.mapDataRecords.length === 0) { - return null; - } - - return sharedData; -} - -const getSharedAnalyticsData = cache(getSharedAnalyticsDataUncached); - -async function getHeroPickrateMatrixUncached( - teamId: number, - dateFrom?: Date, - dateTo?: Date -): Promise { - // Special handling for date range filtering - need custom query - if (dateFrom && dateTo) { - return getHeroPickrateMatrixWithDateRange(teamId, dateFrom, dateTo); - } - - const sharedData = await getSharedAnalyticsData(teamId); - - if (!sharedData) { - return { - players: [], - allHeroes: [], - }; - } - - const { teamRosterSet, mapDataRecords, allPlayerStats } = sharedData; - - // Build player hero data - const playerHeroMap = new Map< - string, - Map }> - >(); - const allHeroesSet = new Set(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - for (const stat of playersOnMap) { - const playerName = stat.player_name; - const heroName = stat.player_hero as HeroName; - - if (!playerHeroMap.has(playerName)) { - playerHeroMap.set(playerName, new Map()); - } - - const playerHeroes = playerHeroMap.get(playerName)!; - if (!playerHeroes.has(heroName)) { - playerHeroes.set(heroName, { playtime: 0, games: new Set() }); - } - - const heroData = playerHeroes.get(heroName)!; - heroData.playtime += stat.hero_time_played; - heroData.games.add(mapDataId); - - allHeroesSet.add(heroName); - } - } - - const players: PlayerHeroData[] = []; - - for (const [playerName, heroesMap] of playerHeroMap.entries()) { - let totalPlaytime = 0; - const heroes: HeroPickrate[] = []; - - for (const [heroName, data] of heroesMap.entries()) { - const role = determineRole(heroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - totalPlaytime += data.playtime; - heroes.push({ - heroName, - role, - playtime: data.playtime, - gamesPlayed: data.games.size, - }); - } - - // Sort by playtime descending - heroes.sort((a, b) => b.playtime - a.playtime); - - players.push({ - playerName, - heroes, - totalPlaytime, - }); - } - - // Sort players by total playtime - players.sort((a, b) => b.totalPlaytime - a.totalPlaytime); - - return { - players, - allHeroes: Array.from(allHeroesSet), - }; -} - -export const getHeroPickrateMatrix = cache(getHeroPickrateMatrixUncached); - -async function getHeroPickrateMatrixWithDateRange( - teamId: number, - dateFrom: Date, - dateTo: Date -): Promise { - const teamRoster = await getTeamRoster(teamId); - const teamRosterSet = new Set(teamRoster); - - if (teamRoster.length === 0) { - return { - players: [], - allHeroes: [], - }; - } - - const dateFilter = { - date: { - gte: dateFrom, - lte: dateTo, - }, - }; - - const allMapDataRecords = await prisma.map.findMany({ - where: { - Scrim: { - Team: { id: teamId }, - ...dateFilter, - }, - }, - select: { - id: true, - name: true, - }, - }); - - const mapDataRecords = allMapDataRecords.filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[mapName as keyof typeof mapNameToMapTypeMapping]; - return mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash; - }); - - if (mapDataRecords.length === 0) { - return { - players: [], - allHeroes: [], - }; - } - - const mapDataIds = mapDataRecords.map((md) => md.id); - - const allPlayerStats = await prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - MapDataId: true, - eliminations: true, - final_blows: true, - deaths: true, - offensive_assists: true, - hero_damage_dealt: true, - damage_taken: true, - healing_dealt: true, - ultimates_earned: true, - ultimates_used: true, - }, - }); - - // Build player hero data - const playerHeroMap = new Map< - string, - Map }> - >(); - const allHeroesSet = new Set(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - for (const stat of playersOnMap) { - const playerName = stat.player_name; - const heroName = stat.player_hero as HeroName; - - if (!playerHeroMap.has(playerName)) { - playerHeroMap.set(playerName, new Map()); - } - - const playerHeroes = playerHeroMap.get(playerName)!; - if (!playerHeroes.has(heroName)) { - playerHeroes.set(heroName, { playtime: 0, games: new Set() }); - } - - const heroData = playerHeroes.get(heroName)!; - heroData.playtime += stat.hero_time_played; - heroData.games.add(mapDataId); - - allHeroesSet.add(heroName); - } - } - - const players: PlayerHeroData[] = []; - - for (const [playerName, heroesMap] of playerHeroMap.entries()) { - let totalPlaytime = 0; - const heroes: HeroPickrate[] = []; - - for (const [heroName, data] of heroesMap.entries()) { - const role = determineRole(heroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - totalPlaytime += data.playtime; - heroes.push({ - heroName, - role, - playtime: data.playtime, - gamesPlayed: data.games.size, - }); - } - - // Sort by playtime descending - heroes.sort((a, b) => b.playtime - a.playtime); - - players.push({ - playerName, - heroes, - totalPlaytime, - }); - } - - // Sort players by total playtime - players.sort((a, b) => b.totalPlaytime - a.totalPlaytime); - - return { - players, - allHeroes: Array.from(allHeroesSet), - }; -} - -async function getPlayerMapPerformanceMatrixUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getSharedAnalyticsData(teamId, dateRange); - - if (!sharedData) { - return { - players: [], - maps: [], - performance: [], - }; - } - - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - // Build lookup maps - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - // Calculate player performance per map - type MapData = { - players: Set; - wins: number; - losses: number; - }; - - const playerMapStats = new Map>(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - const mapName = mapDataRecord.name; - if (!mapName) continue; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - // Get unique players on this map to avoid counting the same player multiple times - // (since players can have multiple entries for different heroes) - const uniquePlayersOnMap = new Set(); - for (const stat of playersOnMap) { - uniquePlayersOnMap.add(stat.player_name); - } - - // Now count each player only once per map - for (const playerName of uniquePlayersOnMap) { - if (!playerMapStats.has(playerName)) { - playerMapStats.set(playerName, new Map()); - } - - const playerMaps = playerMapStats.get(playerName)!; - if (!playerMaps.has(mapName)) { - playerMaps.set(mapName, { - players: new Set(), - wins: 0, - losses: 0, - }); - } - - const mapData = playerMaps.get(mapName)!; - mapData.players.add(playerName); - if (isWin) { - mapData.wins++; - } else { - mapData.losses++; - } - } - } - - const performance: PlayerMapPerformance[] = []; - const uniquePlayers = new Set(); - const uniqueMaps = new Set(); - - for (const [playerName, mapsData] of playerMapStats.entries()) { - uniquePlayers.add(playerName); - - for (const [mapName, data] of mapsData.entries()) { - uniqueMaps.add(mapName); - const gamesPlayed = data.wins + data.losses; - const winrate = gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; - - performance.push({ - playerName, - mapName, - wins: data.wins, - losses: data.losses, - winrate, - gamesPlayed, - }); - } - } - - return { - players: Array.from(uniquePlayers).sort(), - maps: Array.from(uniqueMaps).sort(), - performance, - }; -} - -export const getPlayerMapPerformanceMatrix = cache( - getPlayerMapPerformanceMatrixUncached -); - -async function getHeroPickrateRawDataUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const teamRoster = await getTeamRoster(teamId); - - if (teamRoster.length === 0) { - return { - teamRoster: [], - mapDataRecords: [], - allPlayerStats: [], - }; - } - - const scrimWhereClause: Record = { Team: { id: teamId } }; - if (dateRange) { - scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; - } - - const allMapDataRecords = await prisma.map.findMany({ - where: { Scrim: scrimWhereClause }, - select: { - id: true, - name: true, - Scrim: { - select: { - date: true, - }, - }, - }, - orderBy: { - Scrim: { - date: "desc", - }, - }, - }); - - const mapDataRecords = allMapDataRecords - .filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[ - mapName as keyof typeof mapNameToMapTypeMapping - ]; - return ( - mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash - ); - }) - .map((record) => ({ - id: record.id, - name: record.name, - scrimDate: record.Scrim?.date ?? new Date(), - })); - - if (mapDataRecords.length === 0) { - return { - teamRoster, - mapDataRecords: [], - allPlayerStats: [], - }; - } - - const mapDataIds = mapDataRecords.map((md) => md.id); - - const allPlayerStats = await prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - MapDataId: true, - }, - }); - - return { - teamRoster, - mapDataRecords, - allPlayerStats, - }; -} - -export const getHeroPickrateRawData = cache(getHeroPickrateRawDataUncached); diff --git a/src/data/team-ban-impact-dto.ts b/src/data/team-ban-impact-dto.ts deleted file mode 100644 index 6e53dd300..000000000 --- a/src/data/team-ban-impact-dto.ts +++ /dev/null @@ -1,331 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import { cache } from "react"; -import type { TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type HeroBanImpact = { - hero: string; - totalBans: number; - banRate: number; - winRateWithHero: number; - winRateWithoutHero: number; - winRateDelta: number; - mapsPlayed: number; - mapsBanned: number; -}; - -export type TeamBanImpactAnalysis = { - banImpacts: HeroBanImpact[]; - mostBanned: HeroBanImpact[]; - weakPoints: HeroBanImpact[]; - totalMapsAnalyzed: number; -}; - -export type OurBanImpact = { - hero: string; - totalBans: number; - banRate: number; - winRateWhenBanned: number; - winRateWhenNotBanned: number; - winRateDelta: number; - mapsPlayed: number; - mapsBanned: number; -}; - -export type TeamOurBanAnalysis = { - ourBanImpacts: OurBanImpact[]; - mostBannedByUs: OurBanImpact[]; - strongBans: OurBanImpact[]; - totalMapsAnalyzed: number; -}; - -const MIN_BANS_FOR_SIGNIFICANCE = 3; -const WEAK_POINT_DELTA_THRESHOLD = 0.15; -const STRONG_BAN_DELTA_THRESHOLD = 0.1; - -export type CombinedBanAnalysis = { - received: TeamBanImpactAnalysis; - outgoing: TeamOurBanAnalysis; -}; - -async function getTeamBanImpactAnalysisUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - - const { - teamRosterSet, - mapDataRecords, - mapDataIds, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return createEmptyAnalysis(); - } - - const heroBans = await prisma.heroBan.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { MapDataId: true, team: true, hero: true }, - }); - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - type MapOutcome = { - mapDataId: number; - teamName: string; - isWin: boolean; - bannedHeroes: Set; - heroesBannedByUs: Set; - }; - - const mapOutcomes: MapOutcome[] = []; - - // Index bans by mapDataId - const bansByMapId = new Map(); - for (const ban of heroBans) { - if (!ban.MapDataId) continue; - if (!bansByMapId.has(ban.MapDataId)) { - bansByMapId.set(ban.MapDataId, []); - } - bansByMapId.get(ban.MapDataId)!.push({ team: ban.team, hero: ban.hero }); - } - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - const mapBans = bansByMapId.get(mapDataId) ?? []; - - // Bans AGAINST us (opponent bans our heroes) - const bannedHeroes = new Set(); - // Bans BY us (we ban opponent heroes) - const heroesBannedByUs = new Set(); - for (const ban of mapBans) { - if (ban.team !== teamName) { - bannedHeroes.add(ban.hero); - } else { - heroesBannedByUs.add(ban.hero); - } - } - - mapOutcomes.push({ - mapDataId, - teamName, - isWin, - bannedHeroes, - heroesBannedByUs, - }); - } - - if (mapOutcomes.length === 0) { - return createEmptyAnalysis(); - } - - const totalMaps = mapOutcomes.length; - const overallWins = mapOutcomes.filter((o) => o.isWin).length; - const overallWinRate = totalMaps > 0 ? overallWins / totalMaps : 0; - - // Collect all heroes that appear in bans at all - const allBannedHeroes = new Set(); - for (const outcome of mapOutcomes) { - for (const hero of outcome.bannedHeroes) { - allBannedHeroes.add(hero); - } - } - - const banImpacts: HeroBanImpact[] = []; - - for (const hero of allBannedHeroes) { - const mapsWithBan = mapOutcomes.filter((o) => o.bannedHeroes.has(hero)); - const mapsWithoutBan = mapOutcomes.filter((o) => !o.bannedHeroes.has(hero)); - - const mapsBanned = mapsWithBan.length; - if (mapsBanned < MIN_BANS_FOR_SIGNIFICANCE) continue; - - const winsWhenBanned = mapsWithBan.filter((o) => o.isWin).length; - const winsWhenAvailable = mapsWithoutBan.filter((o) => o.isWin).length; - - const winRateWithoutHero = mapsBanned > 0 ? winsWhenBanned / mapsBanned : 0; - const winRateWithHero = - mapsWithoutBan.length > 0 - ? winsWhenAvailable / mapsWithoutBan.length - : overallWinRate; - - const winRateDelta = winRateWithHero - winRateWithoutHero; - - banImpacts.push({ - hero, - totalBans: mapsBanned, - banRate: mapsBanned / totalMaps, - winRateWithHero, - winRateWithoutHero, - winRateDelta, - mapsPlayed: totalMaps, - mapsBanned, - }); - } - - banImpacts.sort((a, b) => b.banRate - a.banRate); - - const mostBanned = banImpacts.slice(0, 10); - - const weakPoints = banImpacts - .filter( - (impact) => - impact.winRateDelta >= WEAK_POINT_DELTA_THRESHOLD && - impact.mapsBanned >= MIN_BANS_FOR_SIGNIFICANCE - ) - .sort((a, b) => b.winRateDelta - a.winRateDelta); - - const received: TeamBanImpactAnalysis = { - banImpacts, - mostBanned, - weakPoints, - totalMapsAnalyzed: totalMaps, - }; - - // --- Outgoing bans: heroes WE banned and how those maps went --- - - const allHeroesBannedByUs = new Set(); - for (const outcome of mapOutcomes) { - for (const hero of outcome.heroesBannedByUs) { - allHeroesBannedByUs.add(hero); - } - } - - const ourBanImpacts: OurBanImpact[] = []; - - for (const hero of allHeroesBannedByUs) { - const mapsWhereBanned = mapOutcomes.filter((o) => - o.heroesBannedByUs.has(hero) - ); - const mapsWhereNotBanned = mapOutcomes.filter( - (o) => !o.heroesBannedByUs.has(hero) - ); - - const mapsBanned = mapsWhereBanned.length; - if (mapsBanned < MIN_BANS_FOR_SIGNIFICANCE) continue; - - const winsWhenWeBanned = mapsWhereBanned.filter((o) => o.isWin).length; - const winsWhenWeDidNotBan = mapsWhereNotBanned.filter( - (o) => o.isWin - ).length; - - const winRateWhenBanned = - mapsBanned > 0 ? winsWhenWeBanned / mapsBanned : 0; - const winRateWhenNotBanned = - mapsWhereNotBanned.length > 0 - ? winsWhenWeDidNotBan / mapsWhereNotBanned.length - : overallWinRate; - - // Positive delta means we win more often when we ban this hero - const winRateDelta = winRateWhenBanned - winRateWhenNotBanned; - - ourBanImpacts.push({ - hero, - totalBans: mapsBanned, - banRate: mapsBanned / totalMaps, - winRateWhenBanned, - winRateWhenNotBanned, - winRateDelta, - mapsPlayed: totalMaps, - mapsBanned, - }); - } - - ourBanImpacts.sort((a, b) => b.banRate - a.banRate); - - const mostBannedByUs = ourBanImpacts.slice(0, 10); - - const strongBans = ourBanImpacts - .filter( - (impact) => - impact.winRateDelta >= STRONG_BAN_DELTA_THRESHOLD && - impact.mapsBanned >= MIN_BANS_FOR_SIGNIFICANCE - ) - .sort((a, b) => b.winRateDelta - a.winRateDelta); - - const outgoing: TeamOurBanAnalysis = { - ourBanImpacts, - mostBannedByUs, - strongBans, - totalMapsAnalyzed: totalMaps, - }; - - return { received, outgoing }; -} - -function createEmptyAnalysis(): CombinedBanAnalysis { - return { - received: { - banImpacts: [], - mostBanned: [], - weakPoints: [], - totalMapsAnalyzed: 0, - }, - outgoing: { - ourBanImpacts: [], - mostBannedByUs: [], - strongBans: [], - totalMapsAnalyzed: 0, - }, - }; -} - -export const getTeamBanImpactAnalysis = cache(getTeamBanImpactAnalysisUncached); diff --git a/src/data/team-comparison-dto.ts b/src/data/team-comparison-dto.ts deleted file mode 100644 index ccc0de10b..000000000 --- a/src/data/team-comparison-dto.ts +++ /dev/null @@ -1,613 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import type { - TeamComparisonStats, - TeamMapBreakdown, -} from "@/types/team-comparison"; -import type { CalculatedStat, MapType, PlayerStat } from "@prisma/client"; -import { CalculatedStatType } from "@prisma/client"; -import { cache } from "react"; -import type { AggregatedStats } from "./comparison-dto"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getTeamRoster } from "./team-shared-data"; - -/** - * Calculates per-10 value for a stat - */ -function calculatePer10(value: number, timePlayed: number): number { - if (timePlayed === 0) return 0; - return (value / timePlayed) * 600; -} - -/** - * Calculates percentage - */ -function calculatePercentage(value: number, total: number): number { - if (total === 0) return 0; - return (value / total) * 100; -} - -/** - * Aggregates calculated stats for a team - */ -function aggregateCalculatedStatsForTeam( - stats: CalculatedStat[] -): Partial { - const result: Partial = { - fletaDeadliftPercentage: 0, - firstPickPercentage: 0, - firstPickCount: 0, - firstDeathPercentage: 0, - firstDeathCount: 0, - mvpScore: 0, - mapMvpCount: 0, - ajaxCount: 0, - averageUltChargeTime: 0, - averageTimeToUseUlt: 0, - averageDroughtTime: 0, - killsPerUltimate: 0, - duelWinratePercentage: 0, - fightReversalPercentage: 0, - }; - - const counts: Record = {}; - - stats.forEach((stat) => { - switch (stat.stat) { - case CalculatedStatType.FLETA_DEADLIFT_PERCENTAGE: - result.fletaDeadliftPercentage = - (result.fletaDeadliftPercentage ?? 0) + stat.value; - counts.fletaDeadlift = (counts.fletaDeadlift ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_PERCENTAGE: - result.firstPickPercentage = - (result.firstPickPercentage ?? 0) + stat.value; - counts.firstPick = (counts.firstPick ?? 0) + 1; - break; - case CalculatedStatType.FIRST_PICK_COUNT: - result.firstPickCount = (result.firstPickCount ?? 0) + stat.value; - break; - case CalculatedStatType.FIRST_DEATH_PERCENTAGE: - result.firstDeathPercentage = - (result.firstDeathPercentage ?? 0) + stat.value; - counts.firstDeath = (counts.firstDeath ?? 0) + 1; - break; - case CalculatedStatType.FIRST_DEATH_COUNT: - result.firstDeathCount = (result.firstDeathCount ?? 0) + stat.value; - break; - case CalculatedStatType.MVP_SCORE: - result.mvpScore = (result.mvpScore ?? 0) + stat.value; - counts.mvpScore = (counts.mvpScore ?? 0) + 1; - break; - case CalculatedStatType.MAP_MVP_COUNT: - result.mapMvpCount = (result.mapMvpCount ?? 0) + stat.value; - break; - case CalculatedStatType.AJAX_COUNT: - result.ajaxCount = (result.ajaxCount ?? 0) + stat.value; - break; - case CalculatedStatType.AVERAGE_ULT_CHARGE_TIME: - result.averageUltChargeTime = - (result.averageUltChargeTime ?? 0) + stat.value; - counts.ultCharge = (counts.ultCharge ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_TIME_TO_USE_ULT: - result.averageTimeToUseUlt = - (result.averageTimeToUseUlt ?? 0) + stat.value; - counts.timeToUseUlt = (counts.timeToUseUlt ?? 0) + 1; - break; - case CalculatedStatType.AVERAGE_DROUGHT_TIME: - result.averageDroughtTime = - (result.averageDroughtTime ?? 0) + stat.value; - counts.drought = (counts.drought ?? 0) + 1; - break; - case CalculatedStatType.KILLS_PER_ULTIMATE: - result.killsPerUltimate = (result.killsPerUltimate ?? 0) + stat.value; - counts.killsPerUlt = (counts.killsPerUlt ?? 0) + 1; - break; - case CalculatedStatType.DUEL_WINRATE_PERCENTAGE: - result.duelWinratePercentage = - (result.duelWinratePercentage ?? 0) + stat.value; - counts.duelWinrate = (counts.duelWinrate ?? 0) + 1; - break; - case CalculatedStatType.FIGHT_REVERSAL_PERCENTAGE: - result.fightReversalPercentage = - (result.fightReversalPercentage ?? 0) + stat.value; - counts.fightReversal = (counts.fightReversal ?? 0) + 1; - break; - } - }); - - // Average percentage-based stats - if (counts.fletaDeadlift) { - result.fletaDeadliftPercentage = - (result.fletaDeadliftPercentage ?? 0) / counts.fletaDeadlift; - } - if (counts.firstPick) { - result.firstPickPercentage = - (result.firstPickPercentage ?? 0) / counts.firstPick; - } - if (counts.firstDeath) { - result.firstDeathPercentage = - (result.firstDeathPercentage ?? 0) / counts.firstDeath; - } - if (counts.mvpScore) { - result.mvpScore = (result.mvpScore ?? 0) / counts.mvpScore; - } - if (counts.ultCharge) { - result.averageUltChargeTime = - (result.averageUltChargeTime ?? 0) / counts.ultCharge; - } - if (counts.timeToUseUlt) { - result.averageTimeToUseUlt = - (result.averageTimeToUseUlt ?? 0) / counts.timeToUseUlt; - } - if (counts.drought) { - result.averageDroughtTime = - (result.averageDroughtTime ?? 0) / counts.drought; - } - if (counts.killsPerUlt) { - result.killsPerUltimate = - (result.killsPerUltimate ?? 0) / counts.killsPerUlt; - } - if (counts.duelWinrate) { - result.duelWinratePercentage = - (result.duelWinratePercentage ?? 0) / counts.duelWinrate; - } - if (counts.fightReversal) { - result.fightReversalPercentage = - (result.fightReversalPercentage ?? 0) / counts.fightReversal; - } - - return result; -} - -/** - * Aggregates player stats for a team - */ -function aggregateTeamStats( - stats: PlayerStat[], - calculatedStats: CalculatedStat[] -): AggregatedStats { - const totals = stats.reduce( - (acc, stat) => { - acc.eliminations += stat.eliminations; - acc.finalBlows += stat.final_blows; - acc.deaths += stat.deaths; - acc.allDamageDealt += stat.all_damage_dealt; - acc.barrierDamageDealt += stat.barrier_damage_dealt; - acc.heroDamageDealt += stat.hero_damage_dealt; - acc.healingDealt += stat.healing_dealt; - acc.healingReceived += stat.healing_received; - acc.selfHealing += stat.self_healing; - acc.damageTaken += stat.damage_taken; - acc.damageBlocked += stat.damage_blocked; - acc.defensiveAssists += stat.defensive_assists; - acc.offensiveAssists += stat.offensive_assists; - acc.ultimatesEarned += stat.ultimates_earned; - acc.ultimatesUsed += stat.ultimates_used; - acc.multikillBest = Math.max(acc.multikillBest, stat.multikill_best); - acc.multikills += stat.multikills; - acc.soloKills += stat.solo_kills; - acc.objectiveKills += stat.objective_kills; - acc.environmentalKills += stat.environmental_kills; - acc.environmentalDeaths += stat.environmental_deaths; - acc.criticalHits += stat.critical_hits; - acc.shotsFired += stat.shots_fired; - acc.shotsHit += stat.shots_hit; - acc.shotsMissed += stat.shots_missed; - acc.scopedShots += stat.scoped_shots; - acc.scopedShotsHit += stat.scoped_shots_hit; - acc.scopedCriticalHitKills += stat.scoped_critical_hit_kills; - acc.heroTimePlayed += stat.hero_time_played; - return acc; - }, - { - eliminations: 0, - finalBlows: 0, - deaths: 0, - allDamageDealt: 0, - barrierDamageDealt: 0, - heroDamageDealt: 0, - healingDealt: 0, - healingReceived: 0, - selfHealing: 0, - damageTaken: 0, - damageBlocked: 0, - defensiveAssists: 0, - offensiveAssists: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - multikillBest: 0, - multikills: 0, - soloKills: 0, - objectiveKills: 0, - environmentalKills: 0, - environmentalDeaths: 0, - criticalHits: 0, - shotsFired: 0, - shotsHit: 0, - shotsMissed: 0, - scopedShots: 0, - scopedShotsHit: 0, - scopedCriticalHitKills: 0, - heroTimePlayed: 0, - } - ); - - const calculatedAggregates = aggregateCalculatedStatsForTeam(calculatedStats); - - return { - ...totals, - eliminationsPer10: calculatePer10( - totals.eliminations, - totals.heroTimePlayed - ), - finalBlowsPer10: calculatePer10(totals.finalBlows, totals.heroTimePlayed), - deathsPer10: calculatePer10(totals.deaths, totals.heroTimePlayed), - allDamagePer10: calculatePer10( - totals.allDamageDealt, - totals.heroTimePlayed - ), - heroDamagePer10: calculatePer10( - totals.heroDamageDealt, - totals.heroTimePlayed - ), - healingDealtPer10: calculatePer10( - totals.healingDealt, - totals.heroTimePlayed - ), - healingReceivedPer10: calculatePer10( - totals.healingReceived, - totals.heroTimePlayed - ), - damageTakenPer10: calculatePer10(totals.damageTaken, totals.heroTimePlayed), - damageBlockedPer10: calculatePer10( - totals.damageBlocked, - totals.heroTimePlayed - ), - ultimatesEarnedPer10: calculatePer10( - totals.ultimatesEarned, - totals.heroTimePlayed - ), - ultimatesUsedPer10: calculatePer10( - totals.ultimatesUsed, - totals.heroTimePlayed - ), - soloKillsPer10: calculatePer10(totals.soloKills, totals.heroTimePlayed), - objectiveKillsPer10: calculatePer10( - totals.objectiveKills, - totals.heroTimePlayed - ), - defensiveAssistsPer10: calculatePer10( - totals.defensiveAssists, - totals.heroTimePlayed - ), - offensiveAssistsPer10: calculatePer10( - totals.offensiveAssists, - totals.heroTimePlayed - ), - environmentalKillsPer10: calculatePer10( - totals.environmentalKills, - totals.heroTimePlayed - ), - environmentalDeathsPer10: calculatePer10( - totals.environmentalDeaths, - totals.heroTimePlayed - ), - multikillsPer10: calculatePer10(totals.multikills, totals.heroTimePlayed), - barrierDamagePer10: calculatePer10( - totals.barrierDamageDealt, - totals.heroTimePlayed - ), - selfHealingPer10: calculatePer10(totals.selfHealing, totals.heroTimePlayed), - firstPicksPer10: calculatePer10( - calculatedAggregates.firstPickCount ?? 0, - totals.heroTimePlayed - ), - firstDeathsPer10: calculatePer10( - calculatedAggregates.firstDeathCount ?? 0, - totals.heroTimePlayed - ), - mapMvpRate: - calculatedStats.length > 0 - ? ((calculatedAggregates.mapMvpCount ?? 0) / calculatedStats.length) * - 100 - : 0, - ajaxPer10: calculatePer10( - calculatedAggregates.ajaxCount ?? 0, - totals.heroTimePlayed - ), - weaponAccuracy: calculatePercentage(totals.shotsHit, totals.shotsFired), - criticalHitAccuracy: calculatePercentage( - totals.criticalHits, - totals.shotsHit - ), - scopedAccuracy: calculatePercentage( - totals.scopedShotsHit, - totals.scopedShots - ), - scopedCriticalHitAccuracy: calculatePercentage( - totals.scopedCriticalHitKills, - totals.scopedShotsHit - ), - fletaDeadliftPercentage: calculatedAggregates.fletaDeadliftPercentage ?? 0, - firstPickPercentage: calculatedAggregates.firstPickPercentage ?? 0, - firstPickCount: calculatedAggregates.firstPickCount ?? 0, - firstDeathPercentage: calculatedAggregates.firstDeathPercentage ?? 0, - firstDeathCount: calculatedAggregates.firstDeathCount ?? 0, - mvpScore: calculatedAggregates.mvpScore ?? 0, - mapMvpCount: calculatedAggregates.mapMvpCount ?? 0, - ajaxCount: calculatedAggregates.ajaxCount ?? 0, - averageUltChargeTime: calculatedAggregates.averageUltChargeTime ?? 0, - averageTimeToUseUlt: calculatedAggregates.averageTimeToUseUlt ?? 0, - averageDroughtTime: calculatedAggregates.averageDroughtTime ?? 0, - killsPerUltimate: calculatedAggregates.killsPerUltimate ?? 0, - duelWinratePercentage: calculatedAggregates.duelWinratePercentage ?? 0, - fightReversalPercentage: calculatedAggregates.fightReversalPercentage ?? 0, - // Team comparisons don't calculate variance metrics - eliminationsPer10StdDev: 0, - deathsPer10StdDev: 0, - allDamagePer10StdDev: 0, - healingDealtPer10StdDev: 0, - firstPickPercentageStdDev: 0, - consistencyScore: 0, - }; -} - -/** - * Gets team comparison stats for a set of maps - */ -async function getTeamComparisonStatsFn( - mapIds: number[], - teamId: number, - heroes?: HeroName[] -): Promise { - if (mapIds.length === 0) { - throw new Error("At least one map must be provided"); - } - - // Get team roster - const teamRoster = await getTeamRoster(teamId); - const teamRosterSet = new Set(teamRoster); - - if (teamRoster.length === 0) { - throw new Error("No team roster found"); - } - - // Fetch map data - const maps = await prisma.map.findMany({ - where: { id: { in: mapIds } }, - include: { - Scrim: true, - mapData: { - include: { - match_start: true, - round_end: true, - objective_captured: true, - payload_progress: true, - point_progress: true, - }, - }, - }, - }); - - const mapDataIds = maps.flatMap((map) => map.mapData.map((md) => md.id)); - - if (mapDataIds.length === 0) { - throw new Error("No map data found for the provided map IDs"); - } - - // Fetch all player stats - const allPlayerStats = await prisma.playerStat.findMany({ - where: { - MapDataId: { in: mapDataIds }, - ...(heroes && heroes.length > 0 ? { player_hero: { in: heroes } } : {}), - }, - }); - - // Fetch calculated stats - const allCalculatedStats = await prisma.calculatedStat.findMany({ - where: { - MapDataId: { in: mapDataIds }, - ...(heroes && heroes.length > 0 ? { hero: { in: heroes } } : {}), - }, - }); - - // Map MapData IDs back to Map IDs - const mapDataIdToMapId = new Map(); - for (const map of maps) { - for (const mapData of map.mapData) { - mapDataIdToMapId.set(mapData.id, map.id); - } - } - - // Separate stats by team (my team vs enemy team) - const myTeamStats: PlayerStat[] = []; - const enemyTeamStats: PlayerStat[] = []; - const myTeamCalculatedStats: CalculatedStat[] = []; - const enemyTeamCalculatedStats: CalculatedStat[] = []; - - // Per-map breakdown - const perMapBreakdown: TeamMapBreakdown[] = []; - - for (const map of maps) { - const mapMdIds = new Set(map.mapData.map((md) => md.id)); - const mapPlayerStats = allPlayerStats.filter( - (stat) => stat.MapDataId !== null && mapMdIds.has(stat.MapDataId) - ); - - if (mapPlayerStats.length === 0) continue; - - // Find team name for this map using mapData ID - const firstMdId = map.mapData[0]?.id ?? map.id; - const myTeamName = findTeamNameForMapInMemory( - firstMdId, - mapPlayerStats, - teamRosterSet - ); - - if (!myTeamName) continue; - - // Get match start for metadata - const firstMapData = map.mapData[0]; - const matchStart = firstMapData?.match_start[0]; - - // Get final round and captures for winner calculation - const roundEnds = firstMapData?.round_end ?? []; - const finalRound = - roundEnds.length > 0 - ? roundEnds.reduce((latest, current) => - current.round_number > latest.round_number ? current : latest - ) - : null; - - const allCaptures = firstMapData?.objective_captured ?? []; - const team1Captures = allCaptures.filter( - (c) => c.capturing_team === matchStart?.team_1_name - ); - const team2Captures = allCaptures.filter( - (c) => c.capturing_team === matchStart?.team_2_name - ); - const allPayloadProgress = firstMapData?.payload_progress ?? []; - const team1PayloadProgress = allPayloadProgress.filter( - (progress) => progress.capturing_team === matchStart?.team_1_name - ); - const team2PayloadProgress = allPayloadProgress.filter( - (progress) => progress.capturing_team === matchStart?.team_2_name - ); - const allPointProgress = firstMapData?.point_progress ?? []; - const team1PointProgress = allPointProgress.filter( - (progress) => progress.capturing_team === matchStart?.team_1_name - ); - const team2PointProgress = allPointProgress.filter( - (progress) => progress.capturing_team === matchStart?.team_2_name - ); - - // Determine enemy team name - const enemyTeamName = - matchStart?.team_1_name === myTeamName - ? matchStart?.team_2_name - : matchStart?.team_1_name; - - // Separate stats by team for this map - const myTeamMapStats = mapPlayerStats.filter( - (stat) => stat.player_team === myTeamName - ); - const enemyTeamMapStats = mapPlayerStats.filter( - (stat) => stat.player_team === enemyTeamName - ); - - myTeamStats.push(...myTeamMapStats); - enemyTeamStats.push(...enemyTeamMapStats); - - // Separate calculated stats - const mapCalculatedStats = allCalculatedStats.filter((stat) => - mapMdIds.has(stat.MapDataId) - ); - - const myTeamMapCalculatedStats = mapCalculatedStats.filter((stat) => - teamRosterSet.has(stat.playerName) - ); - const enemyTeamMapCalculatedStats = mapCalculatedStats.filter( - (stat) => !teamRosterSet.has(stat.playerName) - ); - - myTeamCalculatedStats.push(...myTeamMapCalculatedStats); - enemyTeamCalculatedStats.push(...enemyTeamMapCalculatedStats); - - // Calculate aggregated stats for this map - const myTeamMapAggregated = aggregateTeamStats( - myTeamMapStats, - myTeamMapCalculatedStats - ); - const enemyTeamMapAggregated = aggregateTeamStats( - enemyTeamMapStats, - enemyTeamMapCalculatedStats - ); - - // Determine winner for this map - const winnerTeamName = calculateWinner({ - matchDetails: matchStart ?? null, - finalRound: finalRound ?? null, - team1Captures, - team2Captures, - team1PayloadProgress, - team2PayloadProgress, - team1PointProgress, - team2PointProgress, - }); - - const winner: TeamMapBreakdown["winner"] = - winnerTeamName === myTeamName - ? "myTeam" - : winnerTeamName === enemyTeamName - ? "enemyTeam" - : winnerTeamName === "N/A" - ? null - : "draw"; - - perMapBreakdown.push({ - mapId: map.id, - mapDataId: firstMapData?.id ?? 0, - mapName: matchStart?.map_name || map.name, - mapType: matchStart?.map_type || ("Control" as MapType), - scrimId: map.scrimId ?? 0, - scrimName: map.Scrim?.name ?? "Unknown", - date: map.Scrim?.date ?? map.createdAt, - replayCode: map.replayCode, - myTeamName: myTeamName ?? "My Team", - enemyTeamName: enemyTeamName ?? "Enemy Team", - myTeamStats: myTeamMapAggregated, - enemyTeamStats: enemyTeamMapAggregated, - winner, - }); - } - - // Sort by date - perMapBreakdown.sort((a, b) => a.date.getTime() - b.date.getTime()); - - // Aggregate overall stats - const myTeamAggregated = aggregateTeamStats( - myTeamStats, - myTeamCalculatedStats - ); - const enemyTeamAggregated = aggregateTeamStats( - enemyTeamStats, - enemyTeamCalculatedStats - ); - - // Get team names from first map - const firstMapTeamName = - perMapBreakdown.length > 0 ? perMapBreakdown[0].myTeamName : "My Team"; - const firstMapEnemyTeamName = - perMapBreakdown.length > 0 - ? perMapBreakdown[0].enemyTeamName - : "Enemy Team"; - - // Count unique players - const myTeamPlayerSet = new Set(myTeamStats.map((stat) => stat.player_name)); - const enemyTeamPlayerSet = new Set( - enemyTeamStats.map((stat) => stat.player_name) - ); - - return { - mapCount: perMapBreakdown.length, - mapIds, - myTeam: { - teamName: firstMapTeamName, - playerCount: myTeamPlayerSet.size, - stats: myTeamAggregated, - }, - enemyTeam: { - teamName: firstMapEnemyTeamName, - playerCount: enemyTeamPlayerSet.size, - stats: enemyTeamAggregated, - }, - perMapBreakdown, - }; -} - -export const getTeamComparisonStats = cache(getTeamComparisonStatsFn); diff --git a/src/data/team-enemy-hero-dto.ts b/src/data/team-enemy-hero-dto.ts deleted file mode 100644 index a44fa7aeb..000000000 --- a/src/data/team-enemy-hero-dto.ts +++ /dev/null @@ -1,156 +0,0 @@ -import "server-only"; - -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import { cache } from "react"; -import type { TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type EnemyHeroWinrate = { - heroName: HeroName; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; -}; - -export type EnemyHeroAnalysis = { - winrateVsHero: EnemyHeroWinrate[]; -}; - -const MIN_GAMES_FOR_INCLUSION = 2; - -async function getEnemyHeroAnalysisUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - excludeClash: true, - dateRange, - }); - - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return { winrateVsHero: [] }; - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - // Track wins/losses per enemy hero across all maps - const enemyHeroData = new Map< - string, - { wins: number; losses: number; maps: Set } - >(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - // Find enemy players for this map - const enemyPlayers = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team !== teamName - ); - - // Track unique enemy heroes per map (a hero may appear multiple times - // if swapped to/from, so deduplicate per map) - const seenHeroes = new Set(); - for (const enemy of enemyPlayers) { - const hero = enemy.player_hero; - if (seenHeroes.has(hero)) continue; - seenHeroes.add(hero); - - if (!enemyHeroData.has(hero)) { - enemyHeroData.set(hero, { wins: 0, losses: 0, maps: new Set() }); - } - - const data = enemyHeroData.get(hero)!; - if (!data.maps.has(mapDataId)) { - data.maps.add(mapDataId); - if (isWin) { - data.wins++; - } else { - data.losses++; - } - } - } - } - - const winrateVsHero: EnemyHeroWinrate[] = []; - - for (const [heroName, data] of enemyHeroData.entries()) { - const gamesPlayed = data.wins + data.losses; - if (gamesPlayed < MIN_GAMES_FOR_INCLUSION) continue; - - winrateVsHero.push({ - heroName: heroName as HeroName, - wins: data.wins, - losses: data.losses, - winrate: (data.wins / gamesPlayed) * 100, - gamesPlayed, - }); - } - - winrateVsHero.sort((a, b) => b.gamesPlayed - a.gamesPlayed); - - return { winrateVsHero }; -} - -export const getEnemyHeroAnalysis = cache(getEnemyHeroAnalysisUncached); diff --git a/src/data/team-fight-stats-dto.tsx b/src/data/team-fight-stats-dto.tsx deleted file mode 100644 index 6afb8de2f..000000000 --- a/src/data/team-fight-stats-dto.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import "server-only"; - -import { mercyRezToKillEvent, ultimateStartToKillEvent } from "@/lib/utils"; -import type { Kill } from "@prisma/client"; -import { cache } from "react"; -import type { ExtendedTeamData, TeamDateRange } from "./team-shared-core"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getExtendedTeamData } from "./team-shared-data"; -export type TeamFightStats = { - totalFights: number; - fightsWon: number; - fightsLost: number; - overallWinrate: number; - - // First pick analysis - firstPickFights: number; - firstPickWins: number; - firstPickWinrate: number; - - // First death analysis - firstDeathFights: number; - firstDeathWins: number; - firstDeathWinrate: number; - - // First ultimate analysis - firstUltFights: number; - firstUltWins: number; - firstUltWinrate: number; - - // Dry fight analysis - dryFights: number; - dryFightWins: number; - dryFightWinrate: number; - - // Ultimate usage - nonDryFights: number; - totalUltsInNonDryFights: number; - avgUltsPerNonDryFight: number; - - // Fight reversals (won after being down 2+ kills) - dryFightReversals: number; - dryFightReversalRate: number; - nonDryFightReversals: number; - nonDryFightReversalRate: number; - - // Ultimate economy - ultimateEfficiency: number; // fights won per ultimate used - avgUltsInWonFights: number; - avgUltsInLostFights: number; - wastedUltimates: number; // ults used in losing fights after 3+ player disadvantage - totalUltsUsed: number; -}; - -type FightEvent = Kill & { - ultimate_id?: number; -}; - -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; - -type FightAnalysis = { - won: boolean; - hadFirstPick: boolean; - hadFirstDeath: boolean; - usedFirstUlt: boolean; - isDryFight: boolean; - isReversal: boolean; - ultCount: number; - wastedUlts: number; // ults used after fight was likely lost -}; - -function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { - // Sort events by match_time to ensure proper ordering - const sortedEvents = [...fight.events].sort( - (a, b) => a.match_time - b.match_time - ); - - // Separate kills and ultimates - const kills = sortedEvents.filter( - (e) => e.event_type === "kill" || e.event_type === "mercy_rez" - ); - const ultimates = sortedEvents.filter( - (e) => e.event_type === "ultimate_start" - ); - - // Count our team's ultimates - const ourUlts = ultimates.filter((u) => u.attacker_team === ourTeamName); - const ultCount = ourUlts.length; - const isDryFight = ultCount === 0; - - // Track kill differential over time to detect "already lost" and reversal scenarios - let ourKills = 0; - let enemyKills = 0; - let wastedUlts = 0; - let wasDown2Plus = false; - - for (const event of sortedEvents) { - if (event.event_type === "mercy_rez") { - if (event.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } - } - - if (enemyKills - ourKills >= 2) { - wasDown2Plus = true; - } - - if ( - event.event_type === "ultimate_start" && - event.attacker_team === ourTeamName - ) { - const killDiff = ourKills - enemyKills; - if (killDiff <= -3) { - wastedUlts++; - } - } - } - - const won = ourKills > enemyKills; - const isReversal = won && wasDown2Plus; - - // Find first pick (first kill event, excluding mercy rez for this check) - const firstKill = kills.find((k) => k.event_type === "kill"); - const hadFirstPick = firstKill - ? firstKill.attacker_team === ourTeamName - : false; - const hadFirstDeath = firstKill - ? firstKill.victim_team === ourTeamName - : false; - - // Find first ultimate usage - const firstUlt = ultimates[0]; - const usedFirstUlt = firstUlt - ? firstUlt.attacker_team === ourTeamName - : false; - - return { - won, - hadFirstPick, - hadFirstDeath, - usedFirstUlt, - isDryFight, - isReversal, - ultCount, - wastedUlts, - }; -} - -async function getTeamFightStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); - return processTeamFightStats(sharedData); -} - -function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { - const { - teamRosterSet, - mapDataIds, - allPlayerStats, - allKills, - allRezzes, - allUltimates, - } = sharedData; - - if (mapDataIds.length === 0) { - return { - totalFights: 0, - fightsWon: 0, - fightsLost: 0, - overallWinrate: 0, - firstPickFights: 0, - firstPickWins: 0, - firstPickWinrate: 0, - firstDeathFights: 0, - firstDeathWins: 0, - firstDeathWinrate: 0, - firstUltFights: 0, - firstUltWins: 0, - firstUltWinrate: 0, - dryFights: 0, - dryFightWins: 0, - dryFightWinrate: 0, - dryFightReversals: 0, - dryFightReversalRate: 0, - nonDryFightReversals: 0, - nonDryFightReversalRate: 0, - nonDryFights: 0, - totalUltsInNonDryFights: 0, - avgUltsPerNonDryFight: 0, - ultimateEfficiency: 0, - avgUltsInWonFights: 0, - avgUltsInLostFights: 0, - wastedUltimates: 0, - totalUltsUsed: 0, - }; - } - - // Build team name lookup for each map based on roster - const teamNameByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } - } - const killsByMap = new Map(); - const rezzesByMap = new Map(); - const ultsByMap = new Map(); - - for (const kill of allKills) { - if (kill.MapDataId) { - if (!killsByMap.has(kill.MapDataId)) { - killsByMap.set(kill.MapDataId, []); - } - killsByMap.get(kill.MapDataId)!.push(kill); - } - } - - for (const rez of allRezzes) { - if (rez.MapDataId) { - if (!rezzesByMap.has(rez.MapDataId)) { - rezzesByMap.set(rez.MapDataId, []); - } - rezzesByMap.get(rez.MapDataId)!.push(rez); - } - } - - for (const ult of allUltimates) { - if (ult.MapDataId) { - if (!ultsByMap.has(ult.MapDataId)) { - ultsByMap.set(ult.MapDataId, []); - } - ultsByMap.get(ult.MapDataId)!.push(ult); - } - } - - // Initialize aggregated statistics - let totalFights = 0; - let fightsWon = 0; - let fightsLost = 0; - let firstPickFights = 0; - let firstPickWins = 0; - let firstDeathFights = 0; - let firstDeathWins = 0; - let firstUltFights = 0; - let firstUltWins = 0; - let dryFights = 0; - let dryFightWins = 0; - let dryFightReversals = 0; - let nonDryFightReversals = 0; - let nonDryFights = 0; - let totalUltsInNonDryFights = 0; - let totalUltsUsed = 0; - let ultsInWonFights = 0; - let ultsInLostFights = 0; - let totalWastedUlts = 0; - - // Process each map (all in-memory now) - for (const mapDataId of mapDataIds) { - const ourTeamName = teamNameByMapId.get(mapDataId); - if (!ourTeamName) continue; - - // Group events into fights (in-memory) - const kills = killsByMap.get(mapDataId) ?? []; - const rezzes = rezzesByMap.get(mapDataId) ?? []; - const ults = ultsByMap.get(mapDataId) ?? []; - - if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) { - continue; - } - - // Build event array - const events: FightEvent[] = [ - ...kills, - ...rezzes.map((rez) => mercyRezToKillEvent(rez)), - ...ults.map((ult) => ({ - ...ultimateStartToKillEvent(ult), - ultimate_id: ult.ultimate_id, - })), - ]; - - // Sort by match_time - events.sort((a, b) => a.match_time - b.match_time); - - // Group into fights - const fights: Fight[] = []; - let currentFight: Fight | null = null; - - for (const event of events) { - if (!currentFight || event.match_time - currentFight.end > 15) { - currentFight = { - events: [event], - start: event.match_time, - end: event.match_time, - }; - fights.push(currentFight); - } else { - currentFight.events.push(event); - currentFight.end = event.match_time; - } - } - - // Analyze each fight - for (const fight of fights) { - const analysis = analyzeFightOutcome(fight, ourTeamName); - - totalFights++; - - if (analysis.won) { - fightsWon++; - } else { - fightsLost++; - } - - if (analysis.hadFirstPick) { - firstPickFights++; - if (analysis.won) firstPickWins++; - } - - if (analysis.hadFirstDeath) { - firstDeathFights++; - if (analysis.won) firstDeathWins++; - } - - if (analysis.usedFirstUlt) { - firstUltFights++; - if (analysis.won) firstUltWins++; - } - - if (analysis.isDryFight) { - dryFights++; - if (analysis.won) dryFightWins++; - if (analysis.isReversal) dryFightReversals++; - } else { - nonDryFights++; - totalUltsInNonDryFights += analysis.ultCount; - if (analysis.isReversal) nonDryFightReversals++; - } - - // Track ultimate economy metrics - totalUltsUsed += analysis.ultCount; - totalWastedUlts += analysis.wastedUlts; - - if (analysis.won) { - ultsInWonFights += analysis.ultCount; - } else { - ultsInLostFights += analysis.ultCount; - } - } - } - - // Calculate percentages - const overallWinrate = totalFights > 0 ? (fightsWon / totalFights) * 100 : 0; - const firstPickWinrate = - firstPickFights > 0 ? (firstPickWins / firstPickFights) * 100 : 0; - const firstDeathWinrate = - firstDeathFights > 0 ? (firstDeathWins / firstDeathFights) * 100 : 0; - const firstUltWinrate = - firstUltFights > 0 ? (firstUltWins / firstUltFights) * 100 : 0; - const dryFightWinrate = dryFights > 0 ? (dryFightWins / dryFights) * 100 : 0; - const dryFightReversalRate = - dryFights > 0 ? (dryFightReversals / dryFights) * 100 : 0; - const nonDryFightReversalRate = - nonDryFights > 0 ? (nonDryFightReversals / nonDryFights) * 100 : 0; - const avgUltsPerNonDryFight = - nonDryFights > 0 ? totalUltsInNonDryFights / nonDryFights : 0; - - // Ultimate economy calculations - const ultimateEfficiency = totalUltsUsed > 0 ? fightsWon / totalUltsUsed : 0; - const avgUltsInWonFights = fightsWon > 0 ? ultsInWonFights / fightsWon : 0; - const avgUltsInLostFights = - fightsLost > 0 ? ultsInLostFights / fightsLost : 0; - - return { - totalFights, - fightsWon, - fightsLost, - overallWinrate, - firstPickFights, - firstPickWins, - firstPickWinrate, - firstDeathFights, - firstDeathWins, - firstDeathWinrate, - firstUltFights, - firstUltWins, - firstUltWinrate, - dryFights, - dryFightWins, - dryFightWinrate, - dryFightReversals, - dryFightReversalRate, - nonDryFightReversals, - nonDryFightReversalRate, - nonDryFights, - totalUltsInNonDryFights, - avgUltsPerNonDryFight, - ultimateEfficiency, - avgUltsInWonFights, - avgUltsInLostFights, - wastedUltimates: totalWastedUlts, - totalUltsUsed, - }; -} - -export const getTeamFightStats = cache(getTeamFightStatsUncached); diff --git a/src/data/team-hero-pool-dto.tsx b/src/data/team-hero-pool-dto.tsx deleted file mode 100644 index 05e222f17..000000000 --- a/src/data/team-hero-pool-dto.tsx +++ /dev/null @@ -1,623 +0,0 @@ -import "server-only"; - -import { determineRole } from "@/lib/player-table-data"; -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import type { - MatchStart, - ObjectiveCaptured, - PayloadProgress, - PointProgress, - RoundEnd, -} from "@prisma/client"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData, getTeamRoster } from "./team-shared-data"; - -export type HeroPlaytime = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - totalPlaytime: number; - gamesPlayed: number; - playedBy: string[]; -}; - -export type HeroWinrate = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - totalPlaytime: number; -}; - -export type HeroSpecialist = { - playerName: string; - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - playtime: number; - gamesPlayed: number; - ownershipPercentage: number; -}; - -export type HeroDiversity = { - totalUniqueHeroes: number; - heroesPerRole: { - Tank: number; - Damage: number; - Support: number; - }; - diversityScore: number; - effectiveHeroPool: number; -}; - -export type HeroPoolAnalysis = { - mostPlayedByRole: { - Tank: HeroPlaytime[]; - Damage: HeroPlaytime[]; - Support: HeroPlaytime[]; - }; - topHeroWinrates: HeroWinrate[]; - specialists: HeroSpecialist[]; - diversity: HeroDiversity; -}; - -export type HeroPoolRawData = { - teamRoster: string[]; - mapDataRecords: { - id: number; - name: string | null; - scrimDate: Date; - }[]; - allPlayerStats: { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - }[]; - matchStarts: MatchStart[]; - finalRounds: RoundEnd[]; - captures: ObjectiveCaptured[]; - payloadProgresses: PayloadProgress[]; - pointProgresses: PointProgress[]; -}; - -async function getHeroPoolAnalysisUncached( - teamId: number, - dateFrom?: Date, - dateTo?: Date -): Promise { - // Special handling for date range filtering - need custom query - if (dateFrom && dateTo) { - return getHeroPoolAnalysisWithCustomDateRange(teamId, dateFrom, dateTo); - } - - // Use shared data layer - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - excludeClash: true, - }); - - return processHeroPoolAnalysis(sharedData); -} - -async function getHeroPoolAnalysisWithCustomDateRange( - teamId: number, - dateFrom: Date, - dateTo: Date -): Promise { - const teamRoster = await getTeamRoster(teamId); - const teamRosterSet = new Set(teamRoster); - - if (teamRoster.length === 0) { - return createEmptyHeroPoolAnalysis(); - } - - const dateFilter = { - date: { - gte: dateFrom, - lte: dateTo, - }, - }; - - const allMapDataRecords = await prisma.map.findMany({ - where: { - Scrim: { - Team: { id: teamId }, - ...dateFilter, - }, - }, - select: { - id: true, - name: true, - }, - }); - - const mapDataRecords = allMapDataRecords.filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[mapName as keyof typeof mapNameToMapTypeMapping]; - return mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash; - }); - - if (mapDataRecords.length === 0) { - return createEmptyHeroPoolAnalysis(); - } - - const mapDataIds = mapDataRecords.map((md) => md.id); - - const [ - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - ] = await Promise.all([ - prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - MapDataId: true, - eliminations: true, - final_blows: true, - deaths: true, - offensive_assists: true, - hero_damage_dealt: true, - damage_taken: true, - healing_dealt: true, - ultimates_earned: true, - ultimates_used: true, - }, - }), - prisma.matchStart.findMany({ - where: { MapDataId: { in: mapDataIds } }, - }), - prisma.roundEnd.findMany({ - where: { - MapDataId: { in: mapDataIds }, - }, - orderBy: { round_number: "desc" }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - ]); - - const baseData: BaseTeamData = { - teamId, - teamRoster, - teamRosterSet, - mapDataRecords, - mapDataIds, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - }; - - return processHeroPoolAnalysis(baseData); -} - -function processHeroPoolAnalysis(sharedData: BaseTeamData): HeroPoolAnalysis { - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return createEmptyHeroPoolAnalysis(); - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - type HeroData = { - playtime: number; - gamesPlayed: Set; - playedBy: Set; - wins: number; - losses: number; - }; - - type PlayerHeroData = { - playtime: number; - gamesPlayed: Set; - }; - - const heroDataMap = new Map(); - const playerHeroMap = new Map>(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - for (const stat of playersOnMap) { - const heroName = stat.player_hero as HeroName; - const playerName = stat.player_name; - - if (!heroDataMap.has(heroName)) { - heroDataMap.set(heroName, { - playtime: 0, - gamesPlayed: new Set(), - playedBy: new Set(), - wins: 0, - losses: 0, - }); - } - - const heroData = heroDataMap.get(heroName)!; - heroData.playtime += stat.hero_time_played; - heroData.gamesPlayed.add(mapDataId); - heroData.playedBy.add(playerName); - - if (isWin) { - heroData.wins++; - } else { - heroData.losses++; - } - - if (!playerHeroMap.has(playerName)) { - playerHeroMap.set(playerName, new Map()); - } - - const playerHeroes = playerHeroMap.get(playerName)!; - if (!playerHeroes.has(heroName)) { - playerHeroes.set(heroName, { - playtime: 0, - gamesPlayed: new Set(), - }); - } - - const playerHeroData = playerHeroes.get(heroName)!; - playerHeroData.playtime += stat.hero_time_played; - playerHeroData.gamesPlayed.add(mapDataId); - } - } - - const mostPlayedByRole: HeroPoolAnalysis["mostPlayedByRole"] = { - Tank: [], - Damage: [], - Support: [], - }; - - const allHeroWinrates: HeroWinrate[] = []; - - for (const [heroName, data] of heroDataMap.entries()) { - const role = determineRole(heroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - const heroPlaytime: HeroPlaytime = { - heroName, - role, - totalPlaytime: data.playtime, - gamesPlayed: data.gamesPlayed.size, - playedBy: Array.from(data.playedBy), - }; - - mostPlayedByRole[role].push(heroPlaytime); - - const gamesPlayed = data.wins + data.losses; - if (gamesPlayed > 0) { - allHeroWinrates.push({ - heroName, - role, - wins: data.wins, - losses: data.losses, - winrate: (data.wins / gamesPlayed) * 100, - gamesPlayed, - totalPlaytime: data.playtime, - }); - } - } - - mostPlayedByRole.Tank.sort((a, b) => b.totalPlaytime - a.totalPlaytime); - mostPlayedByRole.Damage.sort((a, b) => b.totalPlaytime - a.totalPlaytime); - mostPlayedByRole.Support.sort((a, b) => b.totalPlaytime - a.totalPlaytime); - - const topHeroWinrates = allHeroWinrates - .filter((h) => h.gamesPlayed >= 3) - .sort((a, b) => b.winrate - a.winrate) - .slice(0, 10); - - const specialists: HeroSpecialist[] = []; - - for (const [playerName, heroesMap] of playerHeroMap.entries()) { - for (const [heroName, data] of heroesMap.entries()) { - const role = determineRole(heroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - const totalHeroPlaytime = heroDataMap.get(heroName)?.playtime ?? 0; - const ownershipPercentage = - totalHeroPlaytime > 0 ? (data.playtime / totalHeroPlaytime) * 100 : 0; - - if (ownershipPercentage >= 30) { - specialists.push({ - playerName, - heroName, - role, - playtime: data.playtime, - gamesPlayed: data.gamesPlayed.size, - ownershipPercentage, - }); - } - } - } - - specialists.sort((a, b) => b.ownershipPercentage - a.ownershipPercentage); - - const uniqueHeroes = heroDataMap.size; - const heroesPerRole = { - Tank: mostPlayedByRole.Tank.length, - Damage: mostPlayedByRole.Damage.length, - Support: mostPlayedByRole.Support.length, - }; - - const effectiveHeroPool = Array.from(heroDataMap.values()).filter( - (data) => data.gamesPlayed.size >= 3 - ).length; - - const maxHeroes = 41; - const diversityScore = Math.min((uniqueHeroes / maxHeroes) * 100, 100); - - const diversity: HeroDiversity = { - totalUniqueHeroes: uniqueHeroes, - heroesPerRole, - diversityScore, - effectiveHeroPool, - }; - - return { - mostPlayedByRole, - topHeroWinrates, - specialists, - diversity, - }; -} - -function createEmptyHeroPoolAnalysis(): HeroPoolAnalysis { - return { - mostPlayedByRole: { - Tank: [], - Damage: [], - Support: [], - }, - topHeroWinrates: [], - specialists: [], - diversity: { - totalUniqueHeroes: 0, - heroesPerRole: { - Tank: 0, - Damage: 0, - Support: 0, - }, - diversityScore: 0, - effectiveHeroPool: 0, - }, - }; -} - -export const getHeroPoolAnalysis = cache(getHeroPoolAnalysisUncached); - -export async function getHeroPoolAnalysisWithDateRange( - teamId: number, - dateFrom?: Date, - dateTo?: Date -): Promise { - return getHeroPoolAnalysisUncached(teamId, dateFrom, dateTo); -} - -async function getHeroPoolRawDataUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const scrimWhereClause: Record = { Team: { id: teamId } }; - if (dateRange) { - scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; - } - - const allMapDataRecords = await prisma.map.findMany({ - where: { - Scrim: scrimWhereClause, - }, - select: { - id: true, - name: true, - Scrim: { - select: { - date: true, - }, - }, - }, - }); - - const teamRoster = await getTeamRoster(teamId); - - if (teamRoster.length === 0) { - return { - teamRoster: [], - mapDataRecords: [], - allPlayerStats: [], - matchStarts: [], - finalRounds: [], - captures: [], - payloadProgresses: [], - pointProgresses: [], - }; - } - - const mapDataRecords = allMapDataRecords - .filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[ - mapName as keyof typeof mapNameToMapTypeMapping - ]; - return ( - mapType !== $Enums.MapType.Push && mapType !== $Enums.MapType.Clash - ); - }) - .map((record) => ({ - id: record.id, - name: record.name, - scrimDate: record.Scrim?.date ?? new Date(), - })); - - if (mapDataRecords.length === 0) { - return { - teamRoster, - mapDataRecords: [], - allPlayerStats: [], - matchStarts: [], - finalRounds: [], - captures: [], - payloadProgresses: [], - pointProgresses: [], - }; - } - - const mapDataIds = mapDataRecords.map((md) => md.id); - - const [ - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - ] = await Promise.all([ - prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - MapDataId: true, - }, - }), - prisma.matchStart.findMany({ - where: { MapDataId: { in: mapDataIds } }, - }), - prisma.roundEnd.findMany({ - where: { - MapDataId: { in: mapDataIds }, - }, - orderBy: { round_number: "desc" }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - ]); - - return { - teamRoster, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - }; -} - -export const getHeroPoolRawData = cache(getHeroPoolRawDataUncached); diff --git a/src/data/team-hero-swap-dto.ts b/src/data/team-hero-swap-dto.ts deleted file mode 100644 index c7e276542..000000000 --- a/src/data/team-hero-swap-dto.ts +++ /dev/null @@ -1,642 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import type { RoleName } from "@/types/heroes"; -import { getHeroRole } from "@/types/heroes"; -import { cache } from "react"; -import type { TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type SwapTimingBucket = { - bucket: string; - count: number; - percentage: number; -}; - -export type SwapWinrateBucket = { - label: string; - wins: number; - losses: number; - winrate: number; - totalMaps: number; -}; - -export type SwapPair = { - fromHero: string; - toHero: string; - fromRole: RoleName; - toRole: RoleName; - count: number; - timingDistribution: SwapTimingBucket[]; -}; - -export type PlayerSwapStats = { - playerName: string; - totalSwaps: number; - mapsWithSwaps: number; - mapsWithoutSwaps: number; - winrateWithSwaps: number; - winrateWithoutSwaps: number; - topSwapPair: { fromHero: string; toHero: string } | null; - topSwapPairCount: number; -}; - -export type SwapTimingOutcome = { - label: string; - wins: number; - losses: number; - winrate: number; - totalMaps: number; -}; - -export type TeamHeroSwapStats = { - totalSwaps: number; - totalMaps: number; - swapsPerMap: number; - mapsWithSwaps: number; - mapsWithoutSwaps: number; - avgHeroTimeBeforeSwap: number; - - noSwapWinrate: number; - noSwapWins: number; - noSwapLosses: number; - swapWinrate: number; - swapWins: number; - swapLosses: number; - - timingDistribution: SwapTimingBucket[]; - winrateBySwapCount: SwapWinrateBucket[]; - topSwapPairs: SwapPair[]; - playerBreakdown: PlayerSwapStats[]; - timingOutcomes: SwapTimingOutcome[]; -}; - -export type SwapRecord = { - id: number; - match_time: number; - player_team: string; - player_name: string; - player_hero: string; - previous_hero: string; - hero_time_played: number; - MapDataId: number | null; -}; - -const UTILITY_SWAP_HEROES = new Set([ - "Symmetra", - "Lúcio", - "Juno", - "Widowmaker", -]); -const UTILITY_HERO_TIME_THRESHOLD = 25; -const ROUND_START_PROXIMITY_THRESHOLD = 15; - -export function filterUtilityRoundStartSwaps( - swaps: SwapRecord[], - roundStartTimes: number[] -): SwapRecord[] { - const sortedRoundStarts = [...roundStartTimes].sort((a, b) => a - b); - - function isNearRoundStart(matchTime: number): boolean { - for (const rs of sortedRoundStarts) { - if (Math.abs(matchTime - rs) <= ROUND_START_PROXIMITY_THRESHOLD) { - return true; - } - if (rs > matchTime + ROUND_START_PROXIMITY_THRESHOLD) break; - } - return false; - } - - const idsToRemove = new Set(); - - const byPlayer = new Map(); - for (const swap of swaps) { - const existing = byPlayer.get(swap.player_name) ?? []; - existing.push(swap); - byPlayer.set(swap.player_name, existing); - } - - for (const [, playerSwaps] of byPlayer) { - const sorted = playerSwaps.sort((a, b) => a.match_time - b.match_time); - - for (let i = 0; i < sorted.length; i++) { - const swap = sorted[i]; - - // Pattern 1: Swap TO utility hero then swap BACK (e.g., Kiriko → Juno → Kiriko) - if (i < sorted.length - 1) { - const swapBack = sorted[i + 1]; - if ( - UTILITY_SWAP_HEROES.has(swap.player_hero) && - swapBack.previous_hero === swap.player_hero && - swapBack.hero_time_played < UTILITY_HERO_TIME_THRESHOLD && - isNearRoundStart(swap.match_time) - ) { - idsToRemove.add(swap.id); - idsToRemove.add(swapBack.id); - } - } - - // Pattern 2: Started round on utility hero, swapped off immediately - // (e.g., selected Juno at hero screen, swapped to Kiriko after 1s) - if ( - UTILITY_SWAP_HEROES.has(swap.previous_hero) && - swap.hero_time_played < UTILITY_HERO_TIME_THRESHOLD && - isNearRoundStart(swap.match_time) - ) { - idsToRemove.add(swap.id); - } - } - } - - return swaps.filter((s) => !idsToRemove.has(s.id)); -} - -function emptyStats(): TeamHeroSwapStats { - return { - totalSwaps: 0, - totalMaps: 0, - swapsPerMap: 0, - mapsWithSwaps: 0, - mapsWithoutSwaps: 0, - avgHeroTimeBeforeSwap: 0, - noSwapWinrate: 0, - noSwapWins: 0, - noSwapLosses: 0, - swapWinrate: 0, - swapWins: 0, - swapLosses: 0, - timingDistribution: [], - winrateBySwapCount: [], - topSwapPairs: [], - playerBreakdown: [], - timingOutcomes: [], - }; -} - -async function getTeamHeroSwapStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - - if (sharedData.mapDataIds.length === 0) { - return emptyStats(); - } - - const [allHeroSwaps, matchEnds, roundStarts] = await Promise.all([ - prisma.heroSwap.findMany({ - where: { - MapDataId: { in: sharedData.mapDataIds }, - match_time: { not: 0 }, - }, - select: { - id: true, - match_time: true, - player_team: true, - player_name: true, - player_hero: true, - previous_hero: true, - hero_time_played: true, - MapDataId: true, - }, - orderBy: { match_time: "asc" }, - }), - prisma.matchEnd.findMany({ - where: { MapDataId: { in: sharedData.mapDataIds } }, - select: { match_time: true, MapDataId: true }, - }), - prisma.roundStart.findMany({ - where: { MapDataId: { in: sharedData.mapDataIds } }, - select: { match_time: true, MapDataId: true }, - }), - ]); - - const { - teamRosterSet, - mapDataIds, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - const matchEndTimeMap = new Map(); - for (const me of matchEnds) { - if (me.MapDataId) { - const existing = matchEndTimeMap.get(me.MapDataId); - if (!existing || me.match_time > existing) { - matchEndTimeMap.set(me.MapDataId, me.match_time); - } - } - } - - const roundStartsByMap = new Map(); - for (const rs of roundStarts) { - if (rs.MapDataId) { - const existing = roundStartsByMap.get(rs.MapDataId) ?? []; - existing.push(rs.match_time); - roundStartsByMap.set(rs.MapDataId, existing); - } - } - - const teamNameByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } - } - - const winnerByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - winnerByMapId.set(mapDataId, winner); - } - - const swapsByMap = new Map(); - for (const swap of allHeroSwaps) { - if (!swap.MapDataId) continue; - const ourTeam = teamNameByMapId.get(swap.MapDataId); - if (!ourTeam || swap.player_team !== ourTeam) continue; - - const existing = swapsByMap.get(swap.MapDataId) ?? []; - existing.push(swap); - swapsByMap.set(swap.MapDataId, existing); - } - - const filteredSwapsByMap = new Map(); - for (const [mapId, mapSwaps] of swapsByMap) { - const rsTimesForMap = roundStartsByMap.get(mapId) ?? []; - const filtered = filterUtilityRoundStartSwaps(mapSwaps, rsTimesForMap); - if (filtered.length > 0) { - filteredSwapsByMap.set(mapId, filtered); - } - } - - const allFilteredSwaps: SwapRecord[] = []; - for (const swaps of filteredSwapsByMap.values()) { - allFilteredSwaps.push(...swaps); - } - - const totalMaps = mapDataIds.length; - const mapsWithSwaps = filteredSwapsByMap.size; - const mapsWithoutSwaps = totalMaps - mapsWithSwaps; - const totalSwaps = allFilteredSwaps.length; - const swapsPerMap = totalMaps > 0 ? totalSwaps / totalMaps : 0; - - let totalHeroTimeBeforeSwap = 0; - for (const swap of allFilteredSwaps) { - totalHeroTimeBeforeSwap += swap.hero_time_played; - } - const avgHeroTimeBeforeSwap = - totalSwaps > 0 ? totalHeroTimeBeforeSwap / totalSwaps : 0; - - let noSwapWins = 0; - let noSwapLosses = 0; - let swapWins = 0; - let swapLosses = 0; - - const swapCountByMap = new Map(); - - for (const mapDataId of mapDataIds) { - const ourTeam = teamNameByMapId.get(mapDataId); - if (!ourTeam) continue; - const winner = winnerByMapId.get(mapDataId); - if (winner === "N/A") continue; - - const isWin = winner === ourTeam; - const mapSwapCount = filteredSwapsByMap.get(mapDataId)?.length ?? 0; - swapCountByMap.set(mapDataId, mapSwapCount); - - if (mapSwapCount === 0) { - if (isWin) noSwapWins++; - else noSwapLosses++; - } else { - if (isWin) swapWins++; - else swapLosses++; - } - } - - const noSwapTotal = noSwapWins + noSwapLosses; - const swapTotal = swapWins + swapLosses; - const noSwapWinrate = noSwapTotal > 0 ? (noSwapWins / noSwapTotal) * 100 : 0; - const swapWinrate = swapTotal > 0 ? (swapWins / swapTotal) * 100 : 0; - - const buckets: { label: string; min: number; max: number }[] = [ - { label: "0 swaps", min: 0, max: 0 }, - { label: "1 swap", min: 1, max: 1 }, - { label: "2 swaps", min: 2, max: 2 }, - { label: "3+ swaps", min: 3, max: Infinity }, - ]; - - const winrateBySwapCount: SwapWinrateBucket[] = buckets.map((bucket) => { - let wins = 0; - let losses = 0; - - for (const mapDataId of mapDataIds) { - const ourTeam = teamNameByMapId.get(mapDataId); - if (!ourTeam) continue; - const winner = winnerByMapId.get(mapDataId); - if (winner === "N/A") continue; - - const count = swapCountByMap.get(mapDataId) ?? 0; - if (count >= bucket.min && count <= bucket.max) { - if (winner === ourTeam) wins++; - else losses++; - } - } - - const total = wins + losses; - return { - label: bucket.label, - wins, - losses, - winrate: total > 0 ? (wins / total) * 100 : 0, - totalMaps: total, - }; - }); - - const timingBuckets = Array.from({ length: 10 }, (_, i) => ({ - bucket: `${i * 10}-${(i + 1) * 10}%`, - count: 0, - })); - - for (const swap of allFilteredSwaps) { - if (!swap.MapDataId) continue; - const totalTime = matchEndTimeMap.get(swap.MapDataId); - if (!totalTime || totalTime <= 0) continue; - - const pct = (swap.match_time / totalTime) * 100; - const bucketIndex = Math.min(Math.floor(pct / 10), 9); - timingBuckets[bucketIndex].count++; - } - - const timingDistribution: SwapTimingBucket[] = timingBuckets.map((b) => ({ - bucket: b.bucket, - count: b.count, - percentage: totalSwaps > 0 ? (b.count / totalSwaps) * 100 : 0, - })); - - const pairCounts = new Map< - string, - { from: string; to: string; count: number; buckets: number[] } - >(); - for (const swap of allFilteredSwaps) { - const key = `${swap.previous_hero}->${swap.player_hero}`; - let entry = pairCounts.get(key); - if (!entry) { - entry = { - from: swap.previous_hero, - to: swap.player_hero, - count: 0, - buckets: new Array(10).fill(0), - }; - pairCounts.set(key, entry); - } - entry.count++; - - if (swap.MapDataId) { - const totalTime = matchEndTimeMap.get(swap.MapDataId); - if (totalTime && totalTime > 0) { - const pct = (swap.match_time / totalTime) * 100; - const bucketIndex = Math.min(Math.floor(pct / 10), 9); - entry.buckets[bucketIndex]++; - } - } - } - - const topSwapPairs: SwapPair[] = Array.from(pairCounts.values()) - .sort((a, b) => b.count - a.count) - .slice(0, 10) - .map((p) => ({ - fromHero: p.from, - toHero: p.to, - fromRole: getHeroRole(p.from), - toRole: getHeroRole(p.to), - count: p.count, - timingDistribution: p.buckets.map((bucketCount, i) => ({ - bucket: `${i * 10}-${(i + 1) * 10}%`, - count: bucketCount, - percentage: p.count > 0 ? (bucketCount / p.count) * 100 : 0, - })), - })); - - const playerMapsPlayed = new Map>(); - for (const stat of allPlayerStats) { - if (!stat.MapDataId || !teamRosterSet.has(stat.player_name)) continue; - const ourTeam = teamNameByMapId.get(stat.MapDataId); - if (!ourTeam || stat.player_team !== ourTeam) continue; - - const existing = playerMapsPlayed.get(stat.player_name) ?? new Set(); - existing.add(stat.MapDataId); - playerMapsPlayed.set(stat.player_name, existing); - } - - const playerSwapsByMap = new Map>(); - const playerPairCounts = new Map>(); - - for (const swap of allFilteredSwaps) { - if (!swap.MapDataId) continue; - - let mapMap = playerSwapsByMap.get(swap.player_name); - if (!mapMap) { - mapMap = new Map(); - playerSwapsByMap.set(swap.player_name, mapMap); - } - const existing = mapMap.get(swap.MapDataId) ?? []; - existing.push(swap); - mapMap.set(swap.MapDataId, existing); - - let pairs = playerPairCounts.get(swap.player_name); - if (!pairs) { - pairs = new Map(); - playerPairCounts.set(swap.player_name, pairs); - } - const pairKey = `${swap.previous_hero}->${swap.player_hero}`; - pairs.set(pairKey, (pairs.get(pairKey) ?? 0) + 1); - } - - const playerBreakdown: PlayerSwapStats[] = []; - - for (const [playerName, mapsSet] of playerMapsPlayed) { - const playerMapSwaps = playerSwapsByMap.get(playerName); - const mapsWithPlayerSwaps = new Set(); - let totalPlayerSwaps = 0; - - if (playerMapSwaps) { - for (const [mapId, swaps] of playerMapSwaps) { - mapsWithPlayerSwaps.add(mapId); - totalPlayerSwaps += swaps.length; - } - } - - const mapsWithout = new Set(); - for (const mapId of mapsSet) { - if (!mapsWithPlayerSwaps.has(mapId)) { - mapsWithout.add(mapId); - } - } - - let winsWithSwaps = 0; - let lossesWithSwaps = 0; - for (const mapId of mapsWithPlayerSwaps) { - const ourTeam = teamNameByMapId.get(mapId); - if (!ourTeam) continue; - const winner = winnerByMapId.get(mapId); - if (winner === "N/A") continue; - if (winner === ourTeam) winsWithSwaps++; - else lossesWithSwaps++; - } - - let winsWithout = 0; - let lossesWithout = 0; - for (const mapId of mapsWithout) { - const ourTeam = teamNameByMapId.get(mapId); - if (!ourTeam) continue; - const winner = winnerByMapId.get(mapId); - if (winner === "N/A") continue; - if (winner === ourTeam) winsWithout++; - else lossesWithout++; - } - - const totalWith = winsWithSwaps + lossesWithSwaps; - const totalWithout = winsWithout + lossesWithout; - - let topPairKey: string | null = null; - let topPairCount = 0; - const pairs = playerPairCounts.get(playerName); - if (pairs) { - for (const [key, count] of pairs) { - if (count > topPairCount) { - topPairCount = count; - topPairKey = key; - } - } - } - - let topSwapPair: PlayerSwapStats["topSwapPair"] = null; - if (topPairKey) { - const [from, to] = topPairKey.split("->"); - topSwapPair = { fromHero: from, toHero: to }; - } - - playerBreakdown.push({ - playerName, - totalSwaps: totalPlayerSwaps, - mapsWithSwaps: mapsWithPlayerSwaps.size, - mapsWithoutSwaps: mapsWithout.size, - winrateWithSwaps: totalWith > 0 ? (winsWithSwaps / totalWith) * 100 : 0, - winrateWithoutSwaps: - totalWithout > 0 ? (winsWithout / totalWithout) * 100 : 0, - topSwapPair, - topSwapPairCount: topPairCount, - }); - } - - playerBreakdown.sort((a, b) => b.totalSwaps - a.totalSwaps); - - const timingLabels = ["Early (0-33%)", "Mid (33-66%)", "Late (66-100%)"]; - const timingMapSets: { wins: Set; losses: Set }[] = [ - { wins: new Set(), losses: new Set() }, - { wins: new Set(), losses: new Set() }, - { wins: new Set(), losses: new Set() }, - ]; - - for (const swap of allFilteredSwaps) { - if (!swap.MapDataId) continue; - const totalTime = matchEndTimeMap.get(swap.MapDataId); - if (!totalTime || totalTime <= 0) continue; - - const pct = (swap.match_time / totalTime) * 100; - let timingIndex: number; - if (pct < 33.33) timingIndex = 0; - else if (pct < 66.67) timingIndex = 1; - else timingIndex = 2; - - const ourTeam = teamNameByMapId.get(swap.MapDataId); - if (!ourTeam) continue; - const winner = winnerByMapId.get(swap.MapDataId); - if (winner === "N/A") continue; - - if (winner === ourTeam) { - timingMapSets[timingIndex].wins.add(swap.MapDataId); - } else { - timingMapSets[timingIndex].losses.add(swap.MapDataId); - } - } - - const timingOutcomes: SwapTimingOutcome[] = timingLabels.map((label, i) => { - const wins = timingMapSets[i].wins.size; - const losses = timingMapSets[i].losses.size; - const total = wins + losses; - return { - label, - wins, - losses, - winrate: total > 0 ? (wins / total) * 100 : 0, - totalMaps: total, - }; - }); - - return { - totalSwaps, - totalMaps, - swapsPerMap, - mapsWithSwaps, - mapsWithoutSwaps, - avgHeroTimeBeforeSwap, - noSwapWinrate, - noSwapWins, - noSwapLosses, - swapWinrate, - swapWins, - swapLosses, - timingDistribution, - winrateBySwapCount, - topSwapPairs, - playerBreakdown, - timingOutcomes, - }; -} - -export const getTeamHeroSwapStats = cache(getTeamHeroSwapStatsUncached); diff --git a/src/data/team-map-mode-stats-dto.tsx b/src/data/team-map-mode-stats-dto.tsx deleted file mode 100644 index b5051bdd6..000000000 --- a/src/data/team-map-mode-stats-dto.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type MapModeStats = { - mapType: $Enums.MapType; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - avgPlaytime: number; - bestMap: { - name: string; - winrate: number; - } | null; - worstMap: { - name: string; - winrate: number; - } | null; -}; - -export type MapModePerformance = { - overall: { - totalGames: number; - totalWins: number; - totalLosses: number; - overallWinrate: number; - }; - byMode: Record<$Enums.MapType, MapModeStats>; - bestMode: $Enums.MapType | null; - worstMode: $Enums.MapType | null; -}; - -async function getMapModePerformanceUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - return await processMapModePerformance(sharedData); -} - -async function processMapModePerformance( - sharedData: BaseTeamData -): Promise { - const { mapDataRecords: allMapDataRecords } = sharedData; - - if (allMapDataRecords.length === 0) { - return createEmptyMapModePerformance(); - } - - // Need match end data for playtime - const mapDataIds = allMapDataRecords.map((md) => md.id); - - return processMapModePerformanceWithMatchEnds( - sharedData, - allMapDataRecords, - mapDataIds - ); -} - -async function processMapModePerformanceWithMatchEnds( - sharedData: BaseTeamData, - allMapDataRecords: BaseTeamData["mapDataRecords"], - mapDataIds: number[] -): Promise { - const { - teamRosterSet, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - // Fetch match ends for playtime data - const matchEnds = await prisma.matchEnd.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - match_time: true, - MapDataId: true, - }, - }); - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - - const matchEndMap = new Map(); - for (const match of matchEnds) { - if (match.MapDataId) { - matchEndMap.set(match.MapDataId, match.match_time); - } - } - - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - type MapModeData = { - wins: number; - losses: number; - totalPlaytime: number; - mapWinrates: Map; - }; - - const modeData = new Map<$Enums.MapType, MapModeData>(); - - // Only initialize modes that are actually playable competitively - const activeModes = [ - $Enums.MapType.Control, - $Enums.MapType.Hybrid, - $Enums.MapType.Escort, - $Enums.MapType.Flashpoint, - ]; - - for (const mapType of activeModes) { - modeData.set(mapType, { - wins: 0, - losses: 0, - totalPlaytime: 0, - mapWinrates: new Map(), - }); - } - - let totalGames = 0; - let totalWins = 0; - let totalLosses = 0; - - for (const mapDataRecord of allMapDataRecords) { - const mapDataId = mapDataRecord.id; - const mapName = mapDataRecord.name; - - if (!mapName) continue; - - const mapType = - mapNameToMapTypeMapping[mapName as keyof typeof mapNameToMapTypeMapping]; - if (!mapType) continue; - - // Skip Push (can't be calculated) and Clash (no longer played competitively) - if (mapType === $Enums.MapType.Push || mapType === $Enums.MapType.Clash) { - continue; - } - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - const playtime = matchEndMap.get(mapDataId) ?? 0; - - const data = modeData.get(mapType)!; - if (isWin) { - data.wins++; - totalWins++; - } else { - data.losses++; - totalLosses++; - } - data.totalPlaytime += playtime; - totalGames++; - - if (!data.mapWinrates.has(mapName)) { - data.mapWinrates.set(mapName, { wins: 0, losses: 0 }); - } - const mapWinrate = data.mapWinrates.get(mapName)!; - if (isWin) { - mapWinrate.wins++; - } else { - mapWinrate.losses++; - } - } - - // oxlint-disable-next-line @typescript-eslint/consistent-type-assertions - const byMode: Record<$Enums.MapType, MapModeStats> = {} as Record< - $Enums.MapType, - MapModeStats - >; - let bestMode: $Enums.MapType | null = null; - let bestWinrate = -1; - let worstMode: $Enums.MapType | null = null; - let worstWinrate = 101; - - for (const [mapType, data] of modeData.entries()) { - const gamesPlayed = data.wins + data.losses; - const winrate = gamesPlayed > 0 ? (data.wins / gamesPlayed) * 100 : 0; - const avgPlaytime = gamesPlayed > 0 ? data.totalPlaytime / gamesPlayed : 0; - - let bestMap: MapModeStats["bestMap"] = null; - let worstMap: MapModeStats["worstMap"] = null; - - if (data.mapWinrates.size > 0) { - let bestMapWinrate = -1; - let worstMapWinrate = 101; - - for (const [mapName, mapData] of data.mapWinrates.entries()) { - const mapGames = mapData.wins + mapData.losses; - if (mapGames === 0) continue; - - const mapWinrate = (mapData.wins / mapGames) * 100; - - if (mapWinrate > bestMapWinrate) { - bestMapWinrate = mapWinrate; - bestMap = { name: mapName, winrate: mapWinrate }; - } - - if (mapWinrate < worstMapWinrate) { - worstMapWinrate = mapWinrate; - worstMap = { name: mapName, winrate: mapWinrate }; - } - } - } - - byMode[mapType] = { - mapType, - wins: data.wins, - losses: data.losses, - winrate, - gamesPlayed, - avgPlaytime, - bestMap, - worstMap, - }; - - if (gamesPlayed >= 3) { - if (winrate > bestWinrate) { - bestWinrate = winrate; - bestMode = mapType; - } - if (winrate < worstWinrate) { - worstWinrate = winrate; - worstMode = mapType; - } - } - } - - const overallWinrate = totalGames > 0 ? (totalWins / totalGames) * 100 : 0; - - return { - overall: { - totalGames, - totalWins, - totalLosses, - overallWinrate, - }, - byMode, - bestMode, - worstMode, - }; -} - -function createEmptyMapModePerformance(): MapModePerformance { - const emptyStats: MapModeStats = { - mapType: $Enums.MapType.Control, - wins: 0, - losses: 0, - winrate: 0, - gamesPlayed: 0, - avgPlaytime: 0, - bestMap: null, - worstMap: null, - }; - - return { - overall: { - totalGames: 0, - totalWins: 0, - totalLosses: 0, - overallWinrate: 0, - }, - byMode: { - [$Enums.MapType.Control]: { - ...emptyStats, - mapType: $Enums.MapType.Control, - }, - [$Enums.MapType.Hybrid]: { - ...emptyStats, - mapType: $Enums.MapType.Hybrid, - }, - [$Enums.MapType.Escort]: { - ...emptyStats, - mapType: $Enums.MapType.Escort, - }, - [$Enums.MapType.Push]: { ...emptyStats, mapType: $Enums.MapType.Push }, - [$Enums.MapType.Clash]: { ...emptyStats, mapType: $Enums.MapType.Clash }, - [$Enums.MapType.Flashpoint]: { - ...emptyStats, - mapType: $Enums.MapType.Flashpoint, - }, - }, - bestMode: null, - worstMode: null, - }; -} - -export const getMapModePerformance = cache(getMapModePerformanceUncached); diff --git a/src/data/team-matchup-winrate-dto.ts b/src/data/team-matchup-winrate-dto.ts deleted file mode 100644 index dc22a21d7..000000000 --- a/src/data/team-matchup-winrate-dto.ts +++ /dev/null @@ -1,194 +0,0 @@ -import "server-only"; - -import { determineRole } from "@/lib/player-table-data"; -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import { cache } from "react"; -import type { TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type MapHeroEntry = { - heroName: HeroName; - role: "Tank" | "Damage" | "Support"; - playerName: string; - timePlayed: number; -}; - -export type MatchupMapResult = { - mapDataId: number; - mapName: string; - scrimName: string; - date: string; - isWin: boolean; - ourHeroes: MapHeroEntry[]; - enemyHeroes: MapHeroEntry[]; -}; - -export type MatchupWinrateData = { - maps: MatchupMapResult[]; - allOurHeroes: HeroName[]; - allEnemyHeroes: HeroName[]; -}; - -async function getMatchupWinrateDataUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - excludeClash: true, - includeDateInfo: true, - dateRange, - }); - - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return { maps: [], allOurHeroes: [], allEnemyHeroes: [] }; - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - const maps: MatchupMapResult[] = []; - const allOurHeroesSet = new Set(); - const allEnemyHeroesSet = new Set(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - const enemyPlayers = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team !== teamName - ); - - const ourHeroes = buildHeroEntries(playersOnMap); - const enemyHeroes = buildHeroEntries(enemyPlayers); - - for (const h of ourHeroes) allOurHeroesSet.add(h.heroName); - for (const h of enemyHeroes) allEnemyHeroesSet.add(h.heroName); - - const scrim = (mapDataRecord as { Scrim?: { name: string; date: Date } }) - .Scrim; - - maps.push({ - mapDataId, - mapName: mapDataRecord.name ?? "Unknown", - scrimName: scrim?.name ?? "Unknown", - date: scrim?.date?.toISOString() ?? new Date().toISOString(), - isWin, - ourHeroes, - enemyHeroes, - }); - } - - maps.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - const allOurHeroes = Array.from(allOurHeroesSet).sort(); - const allEnemyHeroes = Array.from(allEnemyHeroesSet).sort(); - - return { maps, allOurHeroes, allEnemyHeroes }; -} - -function buildHeroEntries( - playerStats: { - player_name: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - }[] -): MapHeroEntry[] { - // Group by player, pick each player's most-played hero - const playerMap = new Map(); - - for (const stat of playerStats) { - const existing = playerMap.get(stat.player_name); - if (!existing || stat.hero_time_played > existing.timePlayed) { - playerMap.set(stat.player_name, { - heroName: stat.player_hero, - timePlayed: stat.hero_time_played, - }); - } - } - - const entries: MapHeroEntry[] = []; - for (const [playerName, data] of playerMap.entries()) { - const role = determineRole(data.heroName as HeroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - entries.push({ - heroName: data.heroName as HeroName, - role, - playerName, - timePlayed: data.timePlayed, - }); - } - - // Sort by role order (Tank > Damage > Support), then alphabetically - const roleOrder = { Tank: 0, Damage: 1, Support: 2 }; - entries.sort( - (a, b) => - roleOrder[a.role] - roleOrder[b.role] || - a.heroName.localeCompare(b.heroName) - ); - - // Cap at 5 (1 tank + 2 dps + 2 support) - return entries.slice(0, 5); -} - -export const getMatchupWinrateData = cache(getMatchupWinrateDataUncached); diff --git a/src/data/team-performance-trends-dto.tsx b/src/data/team-performance-trends-dto.tsx deleted file mode 100644 index 430d02648..000000000 --- a/src/data/team-performance-trends-dto.tsx +++ /dev/null @@ -1,445 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type WinrateDataPoint = { - date: Date; - winrate: number; - wins: number; - losses: number; - period: string; -}; - -export type RecentFormMatch = { - scrimId: number; - scrimName: string; - date: Date; - mapName: string; - result: "win" | "loss"; -}; - -export type RecentForm = { - last5: RecentFormMatch[]; - last10: RecentFormMatch[]; - last20: RecentFormMatch[]; - last5Winrate: number; - last10Winrate: number; - last20Winrate: number; -}; - -export type StreakInfo = { - currentStreak: { - type: "win" | "loss" | "none"; - count: number; - }; - longestWinStreak: { - count: number; - startDate: Date | null; - endDate: Date | null; - }; - longestLossStreak: { - count: number; - startDate: Date | null; - endDate: Date | null; - }; -}; - -type ProcessedMatchResult = { - scrimId: number; - scrimName: string; - date: Date; - mapName: string; - isWin: boolean; -}; - -async function getTeamMatchResultsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const scrimWhereClause: Record = { Team: { id: teamId } }; - if (dateRange) { - scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; - } - - const allMapDataRecords = await prisma.map.findMany({ - where: { Scrim: scrimWhereClause }, - select: { - id: true, - name: true, - Scrim: { - select: { - id: true, - name: true, - date: true, - }, - }, - }, - orderBy: { - Scrim: { - date: "desc", - }, - }, - }); - - // Filter out Push maps - const mapDataRecords = allMapDataRecords.filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[mapName as keyof typeof mapNameToMapTypeMapping]; - return mapType !== $Enums.MapType.Push; - }); - - if (mapDataRecords.length === 0) { - return []; - } - - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - dateRange, - }); - return processTeamMatchResults(sharedData, mapDataRecords); -} - -function processTeamMatchResults( - sharedData: BaseTeamData, - mapDataRecordsWithScrim: { - id: number; - name: string | null; - Scrim?: { - id: number; - name: string; - date: Date; - } | null; - }[] -): ProcessedMatchResult[] { - const { - teamRosterSet, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - const matchResults: ProcessedMatchResult[] = []; - - for (const mapDataRecord of mapDataRecordsWithScrim) { - const mapDataId = mapDataRecord.id; - const scrim = mapDataRecord.Scrim; - - if (!scrim) continue; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - matchResults.push({ - scrimId: scrim.id, - scrimName: scrim.name, - date: scrim.date, - mapName: mapDataRecord.name ?? "Unknown", - isWin, - }); - } - - return matchResults; -} - -const getTeamMatchResults = cache(getTeamMatchResultsUncached); - -async function getWinrateOverTimeUncached( - teamId: number, - groupBy: "week" | "month" = "week", - dateRange?: TeamDateRange -): Promise { - const matchResults = await getTeamMatchResults(teamId, dateRange); - - if (matchResults.length === 0) { - return []; - } - - type PeriodKey = string; - const periodData = new Map< - PeriodKey, - { - date: Date; - wins: number; - losses: number; - } - >(); - - for (const result of matchResults) { - let periodKey: string; - let periodDate: Date; - - if (groupBy === "week") { - const date = new Date(result.date); - const day = date.getDay(); - const diff = date.getDate() - day + (day === 0 ? -6 : 1); - periodDate = new Date(date.setDate(diff)); - periodDate.setHours(0, 0, 0, 0); - periodKey = periodDate.toISOString(); - } else { - periodDate = new Date(result.date); - periodDate.setDate(1); - periodDate.setHours(0, 0, 0, 0); - periodKey = periodDate.toISOString(); - } - - if (!periodData.has(periodKey)) { - periodData.set(periodKey, { - date: periodDate, - wins: 0, - losses: 0, - }); - } - - const period = periodData.get(periodKey)!; - if (result.isWin) { - period.wins++; - } else { - period.losses++; - } - } - - const dataPoints: WinrateDataPoint[] = Array.from(periodData.values()) - .sort((a, b) => a.date.getTime() - b.date.getTime()) - .map((period) => { - const total = period.wins + period.losses; - const winrate = total > 0 ? (period.wins / total) * 100 : 0; - - let periodLabel: string; - if (groupBy === "week") { - periodLabel = period.date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - } else { - periodLabel = period.date.toLocaleDateString("en-US", { - month: "short", - year: "numeric", - }); - } - - return { - date: period.date, - winrate, - wins: period.wins, - losses: period.losses, - period: periodLabel, - }; - }); - - return dataPoints; -} - -export const getWinrateOverTime = cache(getWinrateOverTimeUncached); - -async function getRecentFormUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const matchResults = await getTeamMatchResults(teamId, dateRange); - - if (matchResults.length === 0) { - return { - last5: [], - last10: [], - last20: [], - last5Winrate: 0, - last10Winrate: 0, - last20Winrate: 0, - }; - } - - const recentMatches: RecentFormMatch[] = matchResults - .slice(0, 20) - .map((result) => ({ - scrimId: result.scrimId, - scrimName: result.scrimName, - date: result.date, - mapName: result.mapName, - result: result.isWin ? "win" : "loss", - })); - - const last5 = recentMatches.slice(0, 5); - const last10 = recentMatches.slice(0, 10); - const last20 = recentMatches; - - function calculateWinrate(matches: RecentFormMatch[]): number { - if (matches.length === 0) return 0; - const wins = matches.filter((m) => m.result === "win").length; - return (wins / matches.length) * 100; - } - - return { - last5, - last10, - last20, - last5Winrate: calculateWinrate(last5), - last10Winrate: calculateWinrate(last10), - last20Winrate: calculateWinrate(last20), - }; -} - -export const getRecentForm = cache(getRecentFormUncached); - -async function getStreakInfoUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const matchResults = await getTeamMatchResults(teamId, dateRange); - - if (matchResults.length === 0) { - return { - currentStreak: { type: "none", count: 0 }, - longestWinStreak: { count: 0, startDate: null, endDate: null }, - longestLossStreak: { count: 0, startDate: null, endDate: null }, - }; - } - - let currentStreak: StreakInfo["currentStreak"] = { type: "none", count: 0 }; - if (matchResults.length > 0) { - const streakType = matchResults[0].isWin ? "win" : "loss"; - let count = 1; - for (let i = 1; i < matchResults.length; i++) { - if (matchResults[i].isWin === matchResults[0].isWin) { - count++; - } else { - break; - } - } - currentStreak = { type: streakType, count }; - } - - let longestWinStreak = { - count: 0, - startDate: null as Date | null, - endDate: null as Date | null, - }; - let longestLossStreak = { - count: 0, - startDate: null as Date | null, - endDate: null as Date | null, - }; - - let currentWinCount = 0; - let currentWinStart: Date | null = null; - let currentLossCount = 0; - let currentLossStart: Date | null = null; - - for (let i = matchResults.length - 1; i >= 0; i--) { - const result = matchResults[i]; - - if (result.isWin) { - if (currentWinCount === 0) { - currentWinStart = result.date; - } - currentWinCount++; - if (currentLossCount > 0) { - if (currentLossCount > longestLossStreak.count) { - longestLossStreak = { - count: currentLossCount, - startDate: currentLossStart, - endDate: matchResults[i + 1].date, - }; - } - currentLossCount = 0; - currentLossStart = null; - } - } else { - if (currentLossCount === 0) { - currentLossStart = result.date; - } - currentLossCount++; - if (currentWinCount > 0) { - if (currentWinCount > longestWinStreak.count) { - longestWinStreak = { - count: currentWinCount, - startDate: currentWinStart, - endDate: matchResults[i + 1].date, - }; - } - currentWinCount = 0; - currentWinStart = null; - } - } - } - - if (currentWinCount > longestWinStreak.count) { - longestWinStreak = { - count: currentWinCount, - startDate: currentWinStart, - endDate: matchResults[0].date, - }; - } - if (currentLossCount > longestLossStreak.count) { - longestLossStreak = { - count: currentLossCount, - startDate: currentLossStart, - endDate: matchResults[0].date, - }; - } - - return { - currentStreak, - longestWinStreak, - longestLossStreak, - }; -} - -export const getStreakInfo = cache(getStreakInfoUncached); diff --git a/src/data/team-prediction-dto.ts b/src/data/team-prediction-dto.ts deleted file mode 100644 index 1dd3a8db3..000000000 --- a/src/data/team-prediction-dto.ts +++ /dev/null @@ -1,138 +0,0 @@ -import "server-only"; - -import { getTeamBanImpactAnalysis } from "@/data/team-ban-impact-dto"; -import { getEnemyHeroAnalysis } from "@/data/team-enemy-hero-dto"; -import { getHeroPoolAnalysis } from "@/data/team-hero-pool-dto"; -import { getMapModePerformance } from "@/data/team-map-mode-stats-dto"; -import { getBestRoleTrios, type RoleTrio } from "@/data/team-role-stats-dto"; -import { getTeamWinrates } from "@/data/team-stats-dto"; -import type { HeroName } from "@/types/heroes"; -import { roleHeroMapping } from "@/types/heroes"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { TeamDateRange } from "./team-shared-core"; - -export type SimulatorContext = { - baseWinrate: number; - totalGames: number; - heroBanDeltas: Record; - heroBanSampleSizes: Record; - ourBanDeltas: Record; - ourBanSampleSizes: Record; - mapWinrates: Record; - mapSampleSizes: Record; - mapModeWinrates: Record; - roleTrioWinrates: RoleTrio[]; - heroPoolWinrates: Record; - heroPoolSampleSizes: Record; - enemyHeroWinrates: Record; - enemyHeroSampleSizes: Record; - availableHeroes: HeroName[]; - availableMaps: string[]; -}; - -const EXCLUDED_MAP_TYPES = new Set<$Enums.MapType>([ - $Enums.MapType.Push, - $Enums.MapType.Clash, -]); - -async function getSimulatorContextUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const [winrates, banAnalysis, heroPool, mapModePerf, roleTrios, enemyHeroes] = - await Promise.all([ - getTeamWinrates(teamId, dateRange), - getTeamBanImpactAnalysis(teamId, dateRange), - getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to), - getMapModePerformance(teamId, dateRange), - getBestRoleTrios(teamId, dateRange), - getEnemyHeroAnalysis(teamId, dateRange), - ]); - - const totalGames = winrates.overallWins + winrates.overallLosses; - const baseWinrate = totalGames > 0 ? winrates.overallWins / totalGames : 0.5; - - const heroBanDeltas: Record = {}; - const heroBanSampleSizes: Record = {}; - for (const impact of banAnalysis.received.banImpacts) { - heroBanDeltas[impact.hero] = impact.winRateDelta; - heroBanSampleSizes[impact.hero] = impact.mapsBanned; - } - - const ourBanDeltas: Record = {}; - const ourBanSampleSizes: Record = {}; - for (const impact of banAnalysis.outgoing.ourBanImpacts) { - ourBanDeltas[impact.hero] = impact.winRateDelta; - ourBanSampleSizes[impact.hero] = impact.mapsBanned; - } - - const mapWinrates: Record = {}; - const mapSampleSizes: Record = {}; - const availableMaps: string[] = []; - - for (const [mapName, mapData] of Object.entries(winrates.byMap)) { - const mapType = - mapNameToMapTypeMapping[mapName as keyof typeof mapNameToMapTypeMapping]; - if (mapType && EXCLUDED_MAP_TYPES.has(mapType)) continue; - - const games = mapData.totalWins + mapData.totalLosses; - if (games === 0) continue; - - mapWinrates[mapName] = mapData.totalWinrate / 100; - mapSampleSizes[mapName] = games; - availableMaps.push(mapName); - } - - availableMaps.sort(); - - const mapModeWinrates: Record = {}; - for (const [modeKey, modeData] of Object.entries(mapModePerf.byMode)) { - if (EXCLUDED_MAP_TYPES.has(modeKey as $Enums.MapType)) continue; - if (modeData.gamesPlayed > 0) { - mapModeWinrates[modeKey] = modeData.winrate / 100; - } - } - - const heroPoolWinrates: Record = {}; - const heroPoolSampleSizes: Record = {}; - for (const hero of heroPool.topHeroWinrates) { - heroPoolWinrates[hero.heroName] = hero.winrate / 100; - heroPoolSampleSizes[hero.heroName] = hero.gamesPlayed; - } - - const enemyHeroWinrates: Record = {}; - const enemyHeroSampleSizes: Record = {}; - for (const hero of enemyHeroes.winrateVsHero) { - enemyHeroWinrates[hero.heroName] = hero.winrate / 100; - enemyHeroSampleSizes[hero.heroName] = hero.gamesPlayed; - } - - const allHeroes: HeroName[] = [ - ...roleHeroMapping.Tank, - ...roleHeroMapping.Damage, - ...roleHeroMapping.Support, - ]; - - return { - baseWinrate, - totalGames, - heroBanDeltas, - heroBanSampleSizes, - ourBanDeltas, - ourBanSampleSizes, - mapWinrates, - mapSampleSizes, - mapModeWinrates, - roleTrioWinrates: roleTrios, - heroPoolWinrates, - heroPoolSampleSizes, - enemyHeroWinrates, - enemyHeroSampleSizes, - availableHeroes: allHeroes, - availableMaps, - }; -} - -export const getSimulatorContext = cache(getSimulatorContextUncached); diff --git a/src/data/team-quick-wins-dto.tsx b/src/data/team-quick-wins-dto.tsx deleted file mode 100644 index 5f4546348..000000000 --- a/src/data/team-quick-wins-dto.tsx +++ /dev/null @@ -1,412 +0,0 @@ -import "server-only"; - -import { mercyRezToKillEvent, ultimateStartToKillEvent } from "@/lib/utils"; -import { calculateWinner } from "@/lib/winrate"; -import type { Kill } from "@prisma/client"; -import { cache } from "react"; -import { getTeamFightStats } from "./team-fight-stats-dto"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import type { ExtendedTeamData, TeamDateRange } from "./team-shared-core"; -import { getExtendedTeamData } from "./team-shared-data"; - -export type QuickWinsStats = { - last10GamesPerformance: { - wins: number; - losses: number; - winrate: number; - }; - bestDayOfWeek: { - day: string; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; - } | null; - averageFightDuration: number | null; - firstPickSuccessRate: { - successfulFirstPicks: number; - totalFirstPicks: number; - successRate: number; - } | null; -}; - -type FightEvent = Kill & { - ultimate_id?: number; -}; - -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; - -function analyzeFightOutcome( - fight: Fight, - ourTeamName: string -): { won: boolean; hadFirstPick: boolean } { - const sortedEvents = [...fight.events].sort( - (a, b) => a.match_time - b.match_time - ); - - const kills = sortedEvents.filter( - (e) => e.event_type === "kill" || e.event_type === "mercy_rez" - ); - - let ourKills = 0; - let enemyKills = 0; - - for (const kill of kills) { - if (kill.event_type === "mercy_rez") { - if (kill.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else { - if (kill.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } - } - } - - const won = ourKills > enemyKills; - - const firstKill = kills.find((k) => k.event_type === "kill"); - const hadFirstPick = firstKill - ? firstKill.attacker_team === ourTeamName - : false; - - return { won, hadFirstPick }; -} - -async function getQuickWinsStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const [fightStats, sharedData] = await Promise.all([ - getTeamFightStats(teamId, dateRange), - getExtendedTeamData(teamId, { - excludePush: true, - excludeClash: true, - includeDateInfo: true, - dateRange, - }), - ]); - - return processQuickWinsStats(sharedData, fightStats); -} - -function processQuickWinsStats( - sharedData: ExtendedTeamData, - fightStats: Awaited> -): QuickWinsStats { - const { - teamRosterSet, - mapDataRecords: rawMapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - allKills, - allRezzes, - allUltimates, - } = sharedData; - - // Type assertion to ensure we have the Scrim data - const mapDataRecords = rawMapDataRecords as { - id: number; - name: string | null; - Scrim?: { - id: number; - name: string; - date: Date; - }; - }[]; - - if (mapDataRecords.length === 0) { - return { - last10GamesPerformance: { wins: 0, losses: 0, winrate: 0 }, - bestDayOfWeek: null, - averageFightDuration: null, - firstPickSuccessRate: null, - }; - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - // Calculate match results - type MatchResult = { - mapDataId: number; - scrimDate: Date; - teamName: string; - isWin: boolean; - }; - - const matchResults: MatchResult[] = []; - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - // Get scrim date, default to current date if not available - const scrimDate = mapDataRecord.Scrim?.date ?? new Date(); - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - matchResults.push({ - mapDataId, - scrimDate, - teamName, - isWin, - }); - } - - // 1. Last 10 games performance - const last10Games = matchResults.slice(0, 10); - const last10Wins = last10Games.filter((m) => m.isWin).length; - const last10Losses = last10Games.length - last10Wins; - const last10Winrate = - last10Games.length > 0 ? (last10Wins / last10Games.length) * 100 : 0; - - // 2. Best day of week - const dayStats = new Map< - number, - { wins: number; losses: number; day: string } - >(); - - const daysOfWeek = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - - for (const result of matchResults) { - const dayOfWeek = result.scrimDate.getDay(); - if (!dayStats.has(dayOfWeek)) { - dayStats.set(dayOfWeek, { - wins: 0, - losses: 0, - day: daysOfWeek[dayOfWeek], - }); - } - const stats = dayStats.get(dayOfWeek)!; - if (result.isWin) { - stats.wins++; - } else { - stats.losses++; - } - } - - let bestDay = null; - let bestWinrate = -1; - - for (const [, stats] of dayStats) { - const total = stats.wins + stats.losses; - if (total >= 3) { - // Minimum 3 games - const winrate = (stats.wins / total) * 100; - if (winrate > bestWinrate) { - bestWinrate = winrate; - bestDay = { - day: stats.day, - wins: stats.wins, - losses: stats.losses, - winrate, - gamesPlayed: total, - }; - } - } - } - - // 3. Average fight duration - Group events into fights - const killsByMap = new Map(); - const rezzesByMap = new Map(); - const ultsByMap = new Map(); - - for (const kill of allKills) { - if (kill.MapDataId) { - if (!killsByMap.has(kill.MapDataId)) { - killsByMap.set(kill.MapDataId, []); - } - killsByMap.get(kill.MapDataId)!.push(kill); - } - } - - for (const rez of allRezzes) { - if (rez.MapDataId) { - if (!rezzesByMap.has(rez.MapDataId)) { - rezzesByMap.set(rez.MapDataId, []); - } - rezzesByMap.get(rez.MapDataId)!.push(rez); - } - } - - for (const ult of allUltimates) { - if (ult.MapDataId) { - if (!ultsByMap.has(ult.MapDataId)) { - ultsByMap.set(ult.MapDataId, []); - } - ultsByMap.get(ult.MapDataId)!.push(ult); - } - } - - let totalFightDuration = 0; - let fightCount = 0; - let successfulFirstPicks = 0; - let totalFirstPicks = 0; - - // Process each map to calculate fight durations and first pick success - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - const ourTeamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!ourTeamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === ourTeamName - ); - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - // Build event array for this map - const kills = killsByMap.get(mapDataId) ?? []; - const rezzes = rezzesByMap.get(mapDataId) ?? []; - const ults = ultsByMap.get(mapDataId) ?? []; - - if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) { - continue; - } - - const events: FightEvent[] = [ - ...kills, - ...rezzes.map((rez) => mercyRezToKillEvent(rez)), - ...ults.map((ult) => ({ - ...ultimateStartToKillEvent(ult), - ultimate_id: ult.ultimate_id, - })), - ]; - - events.sort((a, b) => a.match_time - b.match_time); - - // Group into fights (15 second window) - const fights: Fight[] = []; - let currentFight: Fight | null = null; - - for (const event of events) { - if (!currentFight || event.match_time - currentFight.end > 15) { - currentFight = { - events: [event], - start: event.match_time, - end: event.match_time, - }; - fights.push(currentFight); - } else { - currentFight.events.push(event); - currentFight.end = event.match_time; - } - } - - // Calculate fight durations and first pick success - for (const fight of fights) { - const duration = fight.end - fight.start; - if (duration > 0 && duration < 300) { - totalFightDuration += duration; - fightCount++; - } - - const analysis = analyzeFightOutcome(fight, ourTeamName); - if (analysis.hadFirstPick) { - totalFirstPicks++; - if (analysis.won) { - successfulFirstPicks++; - } - } - } - } - - const averageFightDuration = - fightCount > 0 ? totalFightDuration / fightCount : null; - - // 4. First pick success rate - use the aggregated data - const firstPickSuccessRate = - totalFirstPicks > 0 - ? { - successfulFirstPicks, - totalFirstPicks, - successRate: (successfulFirstPicks / totalFirstPicks) * 100, - } - : fightStats.firstPickFights > 0 - ? { - successfulFirstPicks: fightStats.firstPickWins, - totalFirstPicks: fightStats.firstPickFights, - successRate: fightStats.firstPickWinrate, - } - : null; - - return { - last10GamesPerformance: { - wins: last10Wins, - losses: last10Losses, - winrate: last10Winrate, - }, - bestDayOfWeek: bestDay, - averageFightDuration, - firstPickSuccessRate, - }; -} - -export const getQuickWinsStats = cache(getQuickWinsStatsUncached); diff --git a/src/data/team-role-stats-dto.tsx b/src/data/team-role-stats-dto.tsx deleted file mode 100644 index 043194dbb..000000000 --- a/src/data/team-role-stats-dto.tsx +++ /dev/null @@ -1,646 +0,0 @@ -import "server-only"; - -import { determineRole } from "@/lib/player-table-data"; -import { calculateWinner } from "@/lib/winrate"; -import type { HeroName } from "@/types/heroes"; -import { getTranslations } from "next-intl/server"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, - findTeamNameForMapInMemory, -} from "./team-shared-core"; -import { getBaseTeamData } from "./team-shared-data"; - -export type RoleStats = { - role: "Tank" | "Damage" | "Support"; - totalPlaytime: number; - mapCount: number; - eliminations: number; - finalBlows: number; - deaths: number; - assists: number; - heroDamage: number; - damageTaken: number; - healing: number; - ultimatesEarned: number; - ultimatesUsed: number; - kd: number; - damagePer10Min: number; - healingPer10Min: number; - deathsPer10Min: number; - ultEfficiency: number; -}; - -export type RolePerformanceStats = { - Tank: RoleStats; - Damage: RoleStats; - Support: RoleStats; -}; - -export type RoleBalanceAnalysis = { - overall: string; - weakestRole: "Tank" | "Damage" | "Support" | null; - strongestRole: "Tank" | "Damage" | "Support" | null; - balanceScore: number; - insights: string[]; -}; - -export type RoleTrio = { - tank: string; - dps1: string; - dps2: string; - support1: string; - support2: string; - wins: number; - losses: number; - winrate: number; - gamesPlayed: number; -}; - -async function getRolePerformanceStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - return processRolePerformanceStats(sharedData); -} - -function processRolePerformanceStats( - sharedData: BaseTeamData -): RolePerformanceStats { - const { teamRosterSet, mapDataIds, allPlayerStats } = sharedData; - - if (mapDataIds.length === 0) { - return createEmptyRoleStats(); - } - - const roleAggregates: Record< - "Tank" | "Damage" | "Support", - { - eliminations: number; - finalBlows: number; - deaths: number; - assists: number; - heroDamage: number; - damageTaken: number; - healing: number; - ultimatesEarned: number; - ultimatesUsed: number; - totalPlaytime: number; - mapsPlayed: Set; - } - > = { - Tank: { - eliminations: 0, - finalBlows: 0, - deaths: 0, - assists: 0, - heroDamage: 0, - damageTaken: 0, - healing: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - totalPlaytime: 0, - mapsPlayed: new Set(), - }, - Damage: { - eliminations: 0, - finalBlows: 0, - deaths: 0, - assists: 0, - heroDamage: 0, - damageTaken: 0, - healing: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - totalPlaytime: 0, - mapsPlayed: new Set(), - }, - Support: { - eliminations: 0, - finalBlows: 0, - deaths: 0, - assists: 0, - heroDamage: 0, - damageTaken: 0, - healing: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - totalPlaytime: 0, - mapsPlayed: new Set(), - }, - }; - - for (const stat of allPlayerStats) { - if (!teamRosterSet.has(stat.player_name)) continue; - if (!stat.MapDataId) continue; - - const role = determineRole(stat.player_hero as HeroName); - if (role !== "Tank" && role !== "Damage" && role !== "Support") continue; - - const aggregate = roleAggregates[role]; - aggregate.eliminations += stat.eliminations; - aggregate.finalBlows += stat.final_blows; - aggregate.deaths += stat.deaths; - aggregate.assists += stat.offensive_assists; - aggregate.heroDamage += stat.hero_damage_dealt; - aggregate.damageTaken += stat.damage_taken; - aggregate.healing += stat.healing_dealt; - aggregate.ultimatesEarned += stat.ultimates_earned; - aggregate.ultimatesUsed += stat.ultimates_used; - aggregate.totalPlaytime += stat.hero_time_played; - aggregate.mapsPlayed.add(stat.MapDataId); - } - - function calculateRoleStats( - role: "Tank" | "Damage" | "Support", - aggregate: typeof roleAggregates.Tank - ): RoleStats { - const playtimeInMinutes = aggregate.totalPlaytime / 60; - return { - role, - totalPlaytime: aggregate.totalPlaytime, - mapCount: aggregate.mapsPlayed.size, - eliminations: aggregate.eliminations, - finalBlows: aggregate.finalBlows, - deaths: aggregate.deaths, - assists: aggregate.assists, - heroDamage: aggregate.heroDamage, - damageTaken: aggregate.damageTaken, - healing: aggregate.healing, - ultimatesEarned: aggregate.ultimatesEarned, - ultimatesUsed: aggregate.ultimatesUsed, - kd: aggregate.deaths > 0 ? aggregate.finalBlows / aggregate.deaths : 0, - damagePer10Min: - playtimeInMinutes > 0 - ? (aggregate.heroDamage / playtimeInMinutes) * 10 - : 0, - healingPer10Min: - playtimeInMinutes > 0 - ? (aggregate.healing / playtimeInMinutes) * 10 - : 0, - deathsPer10Min: - playtimeInMinutes > 0 ? (aggregate.deaths / playtimeInMinutes) * 10 : 0, - ultEfficiency: - aggregate.ultimatesUsed > 0 - ? aggregate.eliminations / aggregate.ultimatesUsed - : 0, - }; - } - - return { - Tank: calculateRoleStats("Tank", roleAggregates.Tank), - Damage: calculateRoleStats("Damage", roleAggregates.Damage), - Support: calculateRoleStats("Support", roleAggregates.Support), - }; -} - -function createEmptyRoleStats(): RolePerformanceStats { - const emptyStats: RoleStats = { - role: "Tank", - totalPlaytime: 0, - mapCount: 0, - eliminations: 0, - finalBlows: 0, - deaths: 0, - assists: 0, - heroDamage: 0, - damageTaken: 0, - healing: 0, - ultimatesEarned: 0, - ultimatesUsed: 0, - kd: 0, - damagePer10Min: 0, - healingPer10Min: 0, - deathsPer10Min: 0, - ultEfficiency: 0, - }; - - return { - Tank: { ...emptyStats, role: "Tank" }, - Damage: { ...emptyStats, role: "Damage" }, - Support: { ...emptyStats, role: "Support" }, - }; -} - -export const getRolePerformanceStats = cache(getRolePerformanceStatsUncached); - -async function getRoleBalanceAnalysisUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const t = await getTranslations("teamStatsPage.roleBalanceRadar"); - const roleStats = await getRolePerformanceStats(teamId, dateRange); - - const roles: ("Tank" | "Damage" | "Support")[] = [ - "Tank", - "Damage", - "Support", - ]; - - const totalPlaytime = roles.reduce( - (sum, role) => sum + roleStats[role].totalPlaytime, - 0 - ); - - if (totalPlaytime === 0) { - return { - overall: t("insufficientData"), - weakestRole: null, - strongestRole: null, - balanceScore: 0, - insights: [t("noData")], - }; - } - - const roleScores = roles.map((role) => { - const stats = roleStats[role]; - if (stats.totalPlaytime === 0) return { role, score: 0 }; - - const kdScore = Math.min(stats.kd / 2, 1); - const survivalScore = Math.max(0, 1 - stats.deathsPer10Min / 2); - const ultScore = Math.min(stats.ultEfficiency / 3, 1); - const activityScore = Math.min(stats.totalPlaytime / 3600, 1); - - const score = (kdScore + survivalScore + ultScore + activityScore) / 4; - return { role, score }; - }); - - roleScores.sort((a, b) => b.score - a.score); - const strongestRole = roleScores[0].role; - const weakestRole = roleScores[roleScores.length - 1].role; - - const scoreDiff = - roleScores[0].score - roleScores[roleScores.length - 1].score; - const balanceScore = Math.max(0, 1 - scoreDiff); - - let overall: RoleBalanceAnalysis["overall"] = t("balanced"); - if (balanceScore < 0.6) { - if (roleScores[0].score > 0.7) { - overall = t(`${strongestRole.toLowerCase()}Heavy`); - } - } - - const insights: string[] = []; - - if (balanceScore >= 0.8) { - insights.push(t("excellentBalance")); - } else if (balanceScore >= 0.6) { - insights.push(t("fairlyBalanced")); - } else { - insights.push(t("considerStrengthening", { role: weakestRole })); - } - - roles.forEach((role) => { - const stats = roleStats[role]; - if (stats.kd < 1.0 && stats.totalPlaytime > 600) { - insights.push(t("negativeKD", { role })); - } - if (stats.deathsPer10Min > 7 && stats.totalPlaytime > 600) { - insights.push(t("dyingFrequently", { role })); - } - }); - - return { - overall, - weakestRole: balanceScore < 0.9 ? weakestRole : null, - strongestRole: balanceScore < 0.9 ? strongestRole : null, - balanceScore, - insights, - }; -} - -export const getRoleBalanceAnalysis = cache(getRoleBalanceAnalysisUncached); - -async function getBestRoleTriosUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - return processBestRoleTrios(sharedData); -} - -function processBestRoleTrios(sharedData: BaseTeamData): RoleTrio[] { - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return []; - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - type RosterCombo = { - tank: string; - dps1: string; - dps2: string; - support1: string; - support2: string; - key: string; - wins: number; - losses: number; - }; - - const rosterCombos = new Map(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const playersByRole: Record<"Tank" | "Damage" | "Support", string[]> = { - Tank: [], - Damage: [], - Support: [], - }; - - for (const stat of playersOnMap) { - const role = determineRole(stat.player_hero as HeroName); - if (role === "Tank" || role === "Damage" || role === "Support") { - if (!playersByRole[role].includes(stat.player_name)) { - playersByRole[role].push(stat.player_name); - } - } - } - - if ( - playersByRole.Tank.length !== 1 || - playersByRole.Damage.length !== 2 || - playersByRole.Support.length !== 2 - ) { - continue; - } - - const tank = playersByRole.Tank[0]; - const [dps1, dps2] = playersByRole.Damage.sort(); - const [support1, support2] = playersByRole.Support.sort(); - - const key = `${tank}|${dps1}|${dps2}|${support1}|${support2}`; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - if (!rosterCombos.has(key)) { - rosterCombos.set(key, { - tank, - dps1, - dps2, - support1, - support2, - key, - wins: 0, - losses: 0, - }); - } - - const combo = rosterCombos.get(key)!; - if (isWin) { - combo.wins++; - } else { - combo.losses++; - } - } - - const trios: RoleTrio[] = Array.from(rosterCombos.values()) - .filter((combo) => combo.wins + combo.losses >= 3) - .map((combo) => ({ - tank: combo.tank, - dps1: combo.dps1, - dps2: combo.dps2, - support1: combo.support1, - support2: combo.support2, - wins: combo.wins, - losses: combo.losses, - winrate: - combo.wins + combo.losses > 0 - ? (combo.wins / (combo.wins + combo.losses)) * 100 - : 0, - gamesPlayed: combo.wins + combo.losses, - })) - .sort((a, b) => b.winrate - a.winrate) - .slice(0, 5); - - return trios; -} - -export const getBestRoleTrios = cache(getBestRoleTriosUncached); - -export type RoleWinrateByMap = { - mapName: string; - Tank: { wins: number; losses: number; winrate: number }; - Damage: { wins: number; losses: number; winrate: number }; - Support: { wins: number; losses: number; winrate: number }; -}; - -async function getRoleWinratesByMapUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { dateRange }); - return processRoleWinratesByMap(sharedData); -} - -function processRoleWinratesByMap( - sharedData: BaseTeamData -): RoleWinrateByMap[] { - const { - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (mapDataRecords.length === 0) { - return []; - } - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - - type MapRoleStats = { - Tank: { wins: number; losses: number }; - Damage: { wins: number; losses: number }; - Support: { wins: number; losses: number }; - }; - - const mapRoleData = new Map(); - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - const mapName = mapDataRecord.name ?? "Unknown"; - - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (!teamName) continue; - - const playersOnMap = allPlayerStats.filter( - (stat) => stat.MapDataId === mapDataId && stat.player_team === teamName - ); - - if (!playersOnMap.every((p) => teamRosterSet.has(p.player_name))) continue; - - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - if (!mapRoleData.has(mapName)) { - mapRoleData.set(mapName, { - Tank: { wins: 0, losses: 0 }, - Damage: { wins: 0, losses: 0 }, - Support: { wins: 0, losses: 0 }, - }); - } - - const roleData = mapRoleData.get(mapName)!; - - const rolesPlayed = new Set<"Tank" | "Damage" | "Support">(); - for (const stat of playersOnMap) { - const role = determineRole(stat.player_hero as HeroName); - if (role === "Tank" || role === "Damage" || role === "Support") { - rolesPlayed.add(role); - } - } - - for (const role of rolesPlayed) { - if (isWin) { - roleData[role].wins++; - } else { - roleData[role].losses++; - } - } - } - - const result: RoleWinrateByMap[] = Array.from(mapRoleData.entries()).map( - ([mapName, roleData]) => ({ - mapName, - Tank: { - wins: roleData.Tank.wins, - losses: roleData.Tank.losses, - winrate: - roleData.Tank.wins + roleData.Tank.losses > 0 - ? (roleData.Tank.wins / - (roleData.Tank.wins + roleData.Tank.losses)) * - 100 - : 0, - }, - Damage: { - wins: roleData.Damage.wins, - losses: roleData.Damage.losses, - winrate: - roleData.Damage.wins + roleData.Damage.losses > 0 - ? (roleData.Damage.wins / - (roleData.Damage.wins + roleData.Damage.losses)) * - 100 - : 0, - }, - Support: { - wins: roleData.Support.wins, - losses: roleData.Support.losses, - winrate: - roleData.Support.wins + roleData.Support.losses > 0 - ? (roleData.Support.wins / - (roleData.Support.wins + roleData.Support.losses)) * - 100 - : 0, - }, - }) - ); - - return result.sort((a, b) => { - const totalA = - a.Tank.wins + - a.Tank.losses + - a.Damage.wins + - a.Damage.losses + - a.Support.wins + - a.Support.losses; - const totalB = - b.Tank.wins + - b.Tank.losses + - b.Damage.wins + - b.Damage.losses + - b.Support.wins + - b.Support.losses; - return totalB - totalA; - }); -} - -export const getRoleWinratesByMap = cache(getRoleWinratesByMapUncached); diff --git a/src/data/team-shared-core.ts b/src/data/team-shared-core.ts deleted file mode 100644 index 5ed5500ea..000000000 --- a/src/data/team-shared-core.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { - Kill, - MatchStart, - MercyRez, - ObjectiveCaptured, - PayloadProgress, - PointProgress, - RoundEnd, - UltimateStart, -} from "@prisma/client"; - -export type BaseTeamData = { - teamId: number; - teamRoster: string[]; - teamRosterSet: Set; - mapDataRecords: { - id: number; - name: string | null; - Scrim?: { - id: number; - name: string; - date: Date; - }; - }[]; - mapDataIds: number[]; - allPlayerStats: { - player_name: string; - player_team: string; - player_hero: string; - hero_time_played: number; - MapDataId: number | null; - eliminations: number; - final_blows: number; - deaths: number; - offensive_assists: number; - hero_damage_dealt: number; - damage_taken: number; - healing_dealt: number; - ultimates_earned: number; - ultimates_used: number; - }[]; - matchStarts: MatchStart[]; - finalRounds: RoundEnd[]; - captures: ObjectiveCaptured[]; - payloadProgresses: PayloadProgress[]; - pointProgresses: PointProgress[]; -}; - -export type ExtendedTeamData = BaseTeamData & { - allKills: Kill[]; - allRezzes: MercyRez[]; - allUltimates: UltimateStart[]; -}; - -export type TeamDateRange = { - from: Date; - to: Date; -}; - -export function findTeamNameForMapInMemory( - mapDataId: number, - allPlayerStats: { - player_name: string; - player_team: string; - MapDataId: number | null; - }[], - teamRosterSet: Set -): string | null { - const teamCounts = new Map(); - - for (const stat of allPlayerStats) { - if (stat.MapDataId === mapDataId && teamRosterSet.has(stat.player_name)) { - const currentCount = teamCounts.get(stat.player_team) ?? 0; - teamCounts.set(stat.player_team, currentCount + 1); - } - } - - let maxCount = 0; - let teamName: string | null = null; - - for (const [team, count] of teamCounts.entries()) { - if (count > maxCount) { - maxCount = count; - teamName = team; - } - } - - return teamName; -} - -export function buildFinalRoundMap( - finalRounds: RoundEnd[] -): Map { - const finalRoundMap = new Map(); - - for (const round of finalRounds) { - const mapDataId = round.MapDataId; - if (mapDataId) { - const existing = finalRoundMap.get(mapDataId); - if (!existing || round.round_number > existing.round_number) { - finalRoundMap.set(mapDataId, round); - } - } - } - - return finalRoundMap; -} - -export function buildMatchStartMap( - matchStarts: MatchStart[] -): Map { - const matchStartMap = new Map(); - - for (const match of matchStarts) { - if (match.MapDataId) { - matchStartMap.set(match.MapDataId, match); - } - } - - return matchStartMap; -} - -export function buildCapturesMaps( - captures: ObjectiveCaptured[], - matchStartMap: Map -): { - team1CapturesMap: Map; - team2CapturesMap: Map; -} { - const team1CapturesMap = new Map(); - const team2CapturesMap = new Map(); - - for (const capture of captures) { - const mapDataId = capture.MapDataId; - if (!mapDataId) continue; - - const match = matchStartMap.get(mapDataId); - if (!match) continue; - - if (capture.capturing_team === match.team_1_name) { - if (!team1CapturesMap.has(mapDataId)) { - team1CapturesMap.set(mapDataId, []); - } - team1CapturesMap.get(mapDataId)!.push(capture); - } else if (capture.capturing_team === match.team_2_name) { - if (!team2CapturesMap.has(mapDataId)) { - team2CapturesMap.set(mapDataId, []); - } - team2CapturesMap.get(mapDataId)!.push(capture); - } - } - - return { team1CapturesMap, team2CapturesMap }; -} - -export function buildProgressMaps< - T extends { - MapDataId: number | null; - capturing_team: string; - }, ->( - progressRows: T[], - matchStartMap: Map -): { - team1ProgressMap: Map; - team2ProgressMap: Map; -} { - const team1ProgressMap = new Map(); - const team2ProgressMap = new Map(); - - for (const progressRow of progressRows) { - const mapDataId = progressRow.MapDataId; - if (!mapDataId) continue; - - const match = matchStartMap.get(mapDataId); - if (!match) continue; - - if (progressRow.capturing_team === match.team_1_name) { - if (!team1ProgressMap.has(mapDataId)) { - team1ProgressMap.set(mapDataId, []); - } - team1ProgressMap.get(mapDataId)!.push(progressRow); - } else if (progressRow.capturing_team === match.team_2_name) { - if (!team2ProgressMap.has(mapDataId)) { - team2ProgressMap.set(mapDataId, []); - } - team2ProgressMap.get(mapDataId)!.push(progressRow); - } - } - - return { team1ProgressMap, team2ProgressMap }; -} diff --git a/src/data/team-shared-data.ts b/src/data/team-shared-data.ts deleted file mode 100644 index 4e5d333bd..000000000 --- a/src/data/team-shared-data.ts +++ /dev/null @@ -1,340 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { mapNameToMapTypeMapping } from "@/types/map"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; -import type { - BaseTeamData, - ExtendedTeamData, - TeamDateRange, -} from "./team-shared-core"; - -/** - * Options for fetching base team data - */ -export type BaseTeamDataOptions = { - excludePush?: boolean; - excludeClash?: boolean; - includeDateInfo?: boolean; - dateRange?: TeamDateRange; -}; - -/** - * Gets the team roster for a specific team - * Extracted from team-stats-dto.tsx - */ -async function getTeamRosterUncached(teamId: number): Promise { - const mapRecords = await prisma.map.findMany({ - where: { Scrim: { Team: { id: teamId } } }, - select: { mapData: { select: { id: true } } }, - }); - - const mapDataIds = mapRecords.flatMap((m) => m.mapData.map((md) => md.id)); - - if (mapDataIds.length === 0) { - return []; - } - - const allPlayerStats = await prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - MapDataId: true, - }, - }); - - const playerFrequencyMap = new Map(); - const mapPlayerSets = new Map>(); - - for (const stat of allPlayerStats) { - const mapDataId = stat.MapDataId; - if (!mapDataId) continue; - - if (!mapPlayerSets.has(mapDataId)) { - mapPlayerSets.set(mapDataId, new Set()); - } - - const playersInMap = mapPlayerSets.get(mapDataId)!; - if (!playersInMap.has(stat.player_name)) { - playersInMap.add(stat.player_name); - const currentCount = playerFrequencyMap.get(stat.player_name) ?? 0; - playerFrequencyMap.set(stat.player_name, currentCount + 1); - } - } - - const sortedPlayers = Array.from(playerFrequencyMap.entries()).sort( - (a, b) => b[1] - a[1] - ); - - if (sortedPlayers.length === 0) { - return []; - } - - let anchorPlayer: string | null = null; - for (const [playerName] of sortedPlayers) { - const playerExists = allPlayerStats.some( - (stat) => stat.player_name === playerName - ); - if (playerExists) { - anchorPlayer = playerName; - break; - } - } - - if (!anchorPlayer) { - return []; - } - - // Helper function to find team name for a map using anchor player logic - function findTeamNameForMap(mapDataId: number): string | null { - // Try each player in order of frequency - for (const [playerName] of sortedPlayers) { - for (const stat of allPlayerStats) { - if (stat.MapDataId === mapDataId && stat.player_name === playerName) { - return stat.player_team; - } - } - } - return null; - } - - const rosterPlayers = new Set(); - - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMap(mapDataId); - - if (teamName) { - for (const stat of allPlayerStats) { - if (stat.MapDataId === mapDataId && stat.player_team === teamName) { - rosterPlayers.add(stat.player_name); - } - } - } - } - - return Array.from(rosterPlayers); -} - -export const getTeamRoster = cache(getTeamRosterUncached); - -/** - * Fetches base team data that is common across most DTOs - * This eliminates ~40 redundant database queries - */ -async function getBaseTeamDataUncached( - teamId: number, - options: BaseTeamDataOptions = {} -): Promise { - const { - excludePush = false, - excludeClash = false, - includeDateInfo = false, - dateRange, - } = options; - - // Fetch team roster first - const teamRoster = await getTeamRoster(teamId); - const teamRosterSet = new Set(teamRoster); - - const scrimWhereClause: Record = { Team: { id: teamId } }; - if (dateRange) { - scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; - } - - // Fetch all map records with their MapData IDs - const allMapRecords = await prisma.map.findMany({ - where: { Scrim: scrimWhereClause }, - select: { - id: true, - name: true, - mapData: { select: { id: true } }, - ...(includeDateInfo && { - Scrim: { - select: { - id: true, - name: true, - date: true, - }, - }, - }), - }, - ...(includeDateInfo && { - orderBy: { - Scrim: { - date: "desc" as const, - }, - }, - }), - }); - - // Filter map types if requested - let filteredMapRecords = allMapRecords; - if (excludePush || excludeClash) { - filteredMapRecords = allMapRecords.filter((record) => { - const mapName = record.name; - if (!mapName) return false; - const mapType = - mapNameToMapTypeMapping[ - mapName as keyof typeof mapNameToMapTypeMapping - ]; - if (excludePush && mapType === $Enums.MapType.Push) return false; - if (excludeClash && mapType === $Enums.MapType.Clash) return false; - return true; - }); - } - - // Extract MapData IDs for event table queries - const mapDataIds = filteredMapRecords.flatMap((m) => - m.mapData.map((md) => md.id) - ); - - // Build mapDataRecords using MapData IDs (for compatibility with downstream code) - const mapDataRecords = filteredMapRecords.flatMap((m) => - m.mapData.map((md) => ({ - id: md.id, - name: m.name, - ...("Scrim" in m && m.Scrim ? { Scrim: m.Scrim } : {}), - })) - ); - - // If no maps, return empty data - if (mapDataIds.length === 0) { - return { - teamId, - teamRoster, - teamRosterSet, - mapDataRecords: [], - mapDataIds: [], - allPlayerStats: [], - matchStarts: [], - finalRounds: [], - captures: [], - payloadProgresses: [], - pointProgresses: [], - }; - } - - // Fetch all common data in parallel - const [ - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - ] = await Promise.all([ - prisma.playerStat.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - player_name: true, - player_team: true, - player_hero: true, - hero_time_played: true, - MapDataId: true, - eliminations: true, - final_blows: true, - deaths: true, - offensive_assists: true, - hero_damage_dealt: true, - damage_taken: true, - healing_dealt: true, - ultimates_earned: true, - ultimates_used: true, - }, - }), - prisma.matchStart.findMany({ - where: { MapDataId: { in: mapDataIds } }, - }), - prisma.roundEnd.findMany({ - where: { - MapDataId: { in: mapDataIds }, - }, - orderBy: { round_number: "desc" }, - }), - prisma.objectiveCaptured.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [{ round_number: "asc" }, { match_time: "asc" }], - }), - prisma.payloadProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - prisma.pointProgress.findMany({ - where: { MapDataId: { in: mapDataIds } }, - orderBy: [ - { round_number: "asc" }, - { objective_index: "asc" }, - { match_time: "asc" }, - ], - }), - ]); - - return { - teamId, - teamRoster, - teamRosterSet, - mapDataRecords: mapDataRecords as BaseTeamData["mapDataRecords"], - mapDataIds, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - }; -} - -export const getBaseTeamData = cache(getBaseTeamDataUncached); - -/** - * Fetches extended team data including fight-related events - * Used by fight stats and quick wins DTOs - */ -async function getExtendedTeamDataUncached( - teamId: number, - options: BaseTeamDataOptions = {} -): Promise { - // Get base data first - const baseData = await getBaseTeamData(teamId, options); - - // If no maps, return extended data with empty arrays - if (baseData.mapDataIds.length === 0) { - return { - ...baseData, - allKills: [], - allRezzes: [], - allUltimates: [], - }; - } - - // Fetch fight-related data in parallel - const [allKills, allRezzes, allUltimates] = await Promise.all([ - prisma.kill.findMany({ - where: { MapDataId: { in: baseData.mapDataIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.mercyRez.findMany({ - where: { MapDataId: { in: baseData.mapDataIds } }, - orderBy: { match_time: "asc" }, - }), - prisma.ultimateStart.findMany({ - where: { MapDataId: { in: baseData.mapDataIds } }, - orderBy: { match_time: "asc" }, - }), - ]); - - return { - ...baseData, - allKills, - allRezzes, - allUltimates, - }; -} - -export const getExtendedTeamData = cache(getExtendedTeamDataUncached); diff --git a/src/data/team-stats-dto.tsx b/src/data/team-stats-dto.tsx deleted file mode 100644 index b44792b94..000000000 --- a/src/data/team-stats-dto.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { calculateWinner } from "@/lib/winrate"; -import type { PlayerStat } from "@prisma/client"; -import { cache } from "react"; -import type { BaseTeamData, TeamDateRange } from "./team-shared-core"; -import { - buildCapturesMaps, - buildFinalRoundMap, - buildMatchStartMap, - buildProgressMaps, -} from "./team-shared-core"; -import { getBaseTeamData, getTeamRoster } from "./team-shared-data"; - -// Re-export getTeamRoster for backwards compatibility -export { getTeamRoster }; - -/** - * Gets the roster (list of player names) for a specific team on a specific map. - */ -function getRosterForMap( - mapDataId: number, - teamName: string, - allPlayerStats: { - player_name: string; - player_team: string; - MapDataId: number | null; - }[] -): string[] { - const roster = new Set(); - for (const stat of allPlayerStats) { - if (stat.MapDataId === mapDataId && stat.player_team === teamName) { - roster.add(stat.player_name); - } - } - return Array.from(roster).sort(); -} - -/** - * Finds the anchor player's team name for a specific map. - * Uses fallback to next most frequent players if anchor is not on the map. - */ -function findTeamNameForMap( - mapDataId: number, - allPlayerStats: { - player_name: string; - player_team: string; - MapDataId: number | null; - }[], - sortedPlayers: [string, number][] -): string | null { - // Try each player in order of frequency - for (const [playerName] of sortedPlayers) { - for (const stat of allPlayerStats) { - if (stat.MapDataId === mapDataId && stat.player_name === playerName) { - return stat.player_team; - } - } - } - return null; -} - -export async function getTeamNameForRoster(teamId: number, mapDataId: number) { - const roster = await getTeamRoster(teamId); - - if (roster.length === 0) { - return null; - } - - const playerStats = await prisma.playerStat.findMany({ - where: { MapDataId: mapDataId }, - select: { player_name: true, player_team: true }, - distinct: ["player_name", "player_team"], - }); - - const teamCounts = new Map(); - - for (const stat of playerStats) { - if (roster.includes(stat.player_name)) { - const currentCount = teamCounts.get(stat.player_team) ?? 0; - teamCounts.set(stat.player_team, currentCount + 1); - } - } - - let maxCount = 0; - let teamName: string | null = null; - - for (const [team, count] of teamCounts.entries()) { - if (count > maxCount) { - maxCount = count; - teamName = team; - } - } - - return teamName; -} - -export async function filterTeamPlayerStats( - teamId: number, - mapDataId: number, - playerStats: PlayerStat[] -) { - const teamName = await getTeamNameForRoster(teamId, mapDataId); - - if (!teamName) { - return []; - } - - return playerStats.filter((stat) => stat.player_team === teamName); -} - -type RosterCombination = { - players: string[]; - wins: number; - losses: number; - winrate: number; -}; - -type MapWinrate = { - mapName: string; - totalWins: number; - totalLosses: number; - totalWinrate: number; - rosterVariants: RosterCombination[]; - bestRoster: string[] | null; - bestWinrate: number; -}; - -type TeamWinrates = { - overallWins: number; - overallLosses: number; - overallWinrate: number; - byMap: Record; -}; - -async function getTeamWinratesUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getBaseTeamData(teamId, { - excludePush: true, - dateRange, - }); - return processTeamWinrates(sharedData); -} - -function processTeamWinrates(sharedData: BaseTeamData): TeamWinrates { - const { - teamRoster, - teamRosterSet, - mapDataRecords, - allPlayerStats, - matchStarts, - finalRounds, - captures, - payloadProgresses, - pointProgresses, - } = sharedData; - - if (teamRoster.length === 0 || mapDataRecords.length === 0) { - return { - overallWins: 0, - overallLosses: 0, - overallWinrate: 0, - byMap: {}, - }; - } - - // Build frequency map for anchor player identification - const playerFrequencyMap = new Map(); - const mapPlayerSets = new Map>(); - - for (const stat of allPlayerStats) { - const mapDataId = stat.MapDataId; - if (!mapDataId) continue; - - if (!mapPlayerSets.has(mapDataId)) { - mapPlayerSets.set(mapDataId, new Set()); - } - - const playersInMap = mapPlayerSets.get(mapDataId)!; - if (!playersInMap.has(stat.player_name)) { - playersInMap.add(stat.player_name); - const currentCount = playerFrequencyMap.get(stat.player_name) ?? 0; - playerFrequencyMap.set(stat.player_name, currentCount + 1); - } - } - - const sortedPlayers = Array.from(playerFrequencyMap.entries()).sort( - (a, b) => b[1] - a[1] - ); - - const finalRoundMap = buildFinalRoundMap(finalRounds); - const matchStartMap = buildMatchStartMap(matchStarts); - const { team1CapturesMap, team2CapturesMap } = buildCapturesMaps( - captures, - matchStartMap - ); - const { - team1ProgressMap: team1PayloadProgressMap, - team2ProgressMap: team2PayloadProgressMap, - } = buildProgressMaps(payloadProgresses, matchStartMap); - const { - team1ProgressMap: team1PointProgressMap, - team2ProgressMap: team2PointProgressMap, - } = buildProgressMaps(pointProgresses, matchStartMap); - const mapWinrateData = new Map(); - let overallWins = 0; - let overallLosses = 0; - - for (const mapDataRecord of mapDataRecords) { - const mapDataId = mapDataRecord.id; - const mapName = mapDataRecord.name ?? "Unknown Map"; - - // Find team name for this map - const teamName = findTeamNameForMap( - mapDataId, - allPlayerStats, - sortedPlayers - ); - - if (!teamName) continue; - - // Get roster for this map - const roster = getRosterForMap(mapDataId, teamName, allPlayerStats); - - // VALIDATE: Only process this map if ALL players in the roster are part of the team roster - const allPlayersInTeamRoster = roster.every((player) => - teamRosterSet.has(player) - ); - - if (!allPlayersInTeamRoster) { - // This roster contains players not on the team roster (probably opponent team) - continue; - } - - const rosterKey = roster.join(","); - - // Calculate winner - const matchDetails = matchStartMap.get(mapDataId) ?? null; - const finalRound = finalRoundMap.get(mapDataId) ?? null; - const winner = calculateWinner({ - matchDetails, - finalRound, - team1Captures: team1CapturesMap.get(mapDataId) ?? [], - team2Captures: team2CapturesMap.get(mapDataId) ?? [], - team1PayloadProgress: team1PayloadProgressMap.get(mapDataId) ?? [], - team2PayloadProgress: team2PayloadProgressMap.get(mapDataId) ?? [], - team1PointProgress: team1PointProgressMap.get(mapDataId) ?? [], - team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], - }); - - const isWin = winner === teamName; - - if (isWin) { - overallWins++; - } else { - overallLosses++; - } - - // Initialize map data if needed - if (!mapWinrateData.has(mapName)) { - mapWinrateData.set(mapName, { - mapName, - totalWins: 0, - totalLosses: 0, - totalWinrate: 0, - rosterVariants: [], - bestRoster: null, - bestWinrate: 0, - }); - } - - const currentMapData = mapWinrateData.get(mapName)!; - - // Update map totals - if (isWin) { - currentMapData.totalWins++; - } else { - currentMapData.totalLosses++; - } - - // Find or create roster variant - let rosterVariant = currentMapData.rosterVariants.find( - (rv) => rv.players.join(",") === rosterKey - ); - - if (!rosterVariant) { - rosterVariant = { - players: roster, - wins: 0, - losses: 0, - winrate: 0, - }; - currentMapData.rosterVariants.push(rosterVariant); - } - - // Update roster variant stats - if (isWin) { - rosterVariant.wins++; - } else { - rosterVariant.losses++; - } - } - - // 6. Calculate final percentages and find best rosters - for (const mapData of mapWinrateData.values()) { - const total = mapData.totalWins + mapData.totalLosses; - mapData.totalWinrate = total > 0 ? (mapData.totalWins / total) * 100 : 0; - - // Calculate winrate for each roster variant - for (const variant of mapData.rosterVariants) { - const variantTotal = variant.wins + variant.losses; - variant.winrate = - variantTotal > 0 ? (variant.wins / variantTotal) * 100 : 0; - } - - // Sort roster variants by winrate (descending) - mapData.rosterVariants.sort((a, b) => b.winrate - a.winrate); - - // Set best roster - if (mapData.rosterVariants.length > 0) { - mapData.bestRoster = mapData.rosterVariants[0].players; - mapData.bestWinrate = mapData.rosterVariants[0].winrate; - } - } - - // 7. Build final result - const byMap: Record = {}; - for (const [mapName, data] of mapWinrateData.entries()) { - byMap[mapName] = data; - } - - const overallTotal = overallWins + overallLosses; - const overallWinrate = - overallTotal > 0 ? (overallWins / overallTotal) * 100 : 0; - - return { - overallWins, - overallLosses, - overallWinrate, - byMap, - }; -} - -export const getTeamWinrates = cache(getTeamWinratesUncached); - -async function getTopMapsByPlaytimeFn( - teamId: number, - dateRange?: TeamDateRange -) { - const scrimWhereClause: Record = { - Team: { id: teamId }, - }; - if (dateRange) { - scrimWhereClause.date = { gte: dateRange.from, lte: dateRange.to }; - } - - const maps = await prisma.map.findMany({ - where: { Scrim: scrimWhereClause }, - select: { - id: true, - name: true, - mapData: { select: { id: true } }, - }, - }); - - if (maps.length === 0) { - return []; - } - - const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); - - if (mapDataIds.length === 0) { - return []; - } - - // Build a reverse map from MapData.id -> map name - const mapDataIdToName = new Map(); - for (const map of maps) { - for (const md of map.mapData) { - mapDataIdToName.set(md.id, map.name ?? "Unknown Map"); - } - } - - const matchEnds = await prisma.matchEnd.findMany({ - where: { MapDataId: { in: mapDataIds } }, - select: { - match_time: true, - MapDataId: true, - }, - }); - - // Aggregate playtime by map name - const playtimeByMapName = new Map(); - - for (const matchEnd of matchEnds) { - const mdId = matchEnd.MapDataId; - if (!mdId) continue; - - const mapName = mapDataIdToName.get(mdId) ?? "Unknown Map"; - const currentPlaytime = playtimeByMapName.get(mapName) ?? 0; - playtimeByMapName.set(mapName, currentPlaytime + matchEnd.match_time); - } - - // Convert to array format - const mapsWithPlaytime = Array.from(playtimeByMapName.entries()).map( - ([name, playtime]) => ({ - name, - playtime, - }) - ); - - return mapsWithPlaytime.sort((a, b) => b.playtime - a.playtime); -} - -export const getTopMapsByPlaytime = cache(getTopMapsByPlaytimeFn); - -async function getTop5MapsByPlaytimeFn( - teamId: number, - dateRange?: TeamDateRange -) { - const top5Maps = await getTopMapsByPlaytime(teamId, dateRange); - return top5Maps.slice(0, 5); -} - -export const getTop5MapsByPlaytime = cache(getTop5MapsByPlaytimeFn); - -async function getBestMapByWinrateFn( - teamId: number, - dateRange?: TeamDateRange -) { - const [winrates, top5Maps] = await Promise.all([ - getTeamWinrates(teamId, dateRange), - getTopMapsByPlaytime(teamId, dateRange), - ]); - - const mapsWithStats = Object.keys(winrates.byMap).map((map) => ({ - mapName: map, - playtime: top5Maps.find((m) => m.name === map)?.playtime ?? 0, - winrate: winrates.byMap[map].totalWinrate, - })); - - return mapsWithStats.sort((a, b) => { - if (b.winrate !== a.winrate) { - return b.winrate - a.winrate; - } - return b.playtime - a.playtime; - })[0]; -} - -export const getBestMapByWinrate = cache(getBestMapByWinrateFn); - -/** - * Finds the map with the lowest winrate, then breaks ties by playtime. - */ -async function getBlindSpotMapFn(teamId: number, dateRange?: TeamDateRange) { - const [winrates, topMaps] = await Promise.all([ - getTeamWinrates(teamId, dateRange), - getTopMapsByPlaytime(teamId, dateRange), - ]); - - const mapsWithStats = Object.keys(winrates.byMap).map((map) => ({ - mapName: map, - playtime: topMaps.find((m) => m.name === map)?.playtime ?? 0, - winrate: winrates.byMap[map].totalWinrate, - })); - - const sortedMapsWithStats = mapsWithStats.sort((a, b) => { - if (b.winrate !== a.winrate) { - return a.winrate - b.winrate; - } - return b.playtime - a.playtime; - }); - - return sortedMapsWithStats[0]; -} - -export const getBlindSpotMap = cache(getBlindSpotMapFn); diff --git a/src/data/team-ult-impact-dto.ts b/src/data/team-ult-impact-dto.ts deleted file mode 100644 index 6b81ab29c..000000000 --- a/src/data/team-ult-impact-dto.ts +++ /dev/null @@ -1,308 +0,0 @@ -import "server-only"; - -import { cache } from "react"; -import type { ExtendedTeamData, TeamDateRange } from "./team-shared-core"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getExtendedTeamData } from "./team-shared-data"; - -export type ScenarioStats = { - fights: number; - wins: number; - losses: number; - winrate: number; -}; - -export type HeroUltImpact = { - hero: string; - totalFightsAnalyzed: number; - scenarios: { - uncontestedOurs: ScenarioStats; - uncontestedTheirs: ScenarioStats; - mirrorOursFirst: ScenarioStats; - mirrorTheirsFirst: ScenarioStats; - }; -}; - -export type UltImpactAnalysis = { - byHero: Record; - availableHeroes: string[]; -}; - -function emptyScenario(): ScenarioStats { - return { fights: 0, wins: 0, losses: 0, winrate: 0 }; -} - -function finalizeScenario(s: ScenarioStats): ScenarioStats { - return { - ...s, - winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0, - }; -} - -type FightEvent = { - event_type: string; - match_time: number; - attacker_team: string; - attacker_hero: string; - victim_team?: string; -}; - -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; - -function processUltImpactAnalysis( - sharedData: ExtendedTeamData -): UltImpactAnalysis { - const { - teamRosterSet, - mapDataIds, - allPlayerStats, - allKills, - allRezzes, - allUltimates, - } = sharedData; - - if (mapDataIds.length === 0) { - return { byHero: {}, availableHeroes: [] }; - } - - // Build team name lookup for each map - const teamNameByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } - } - - // Group events by MapDataId - const killsByMap = new Map(); - const rezzesByMap = new Map(); - const ultsByMap = new Map(); - - for (const kill of allKills) { - if (kill.MapDataId) { - if (!killsByMap.has(kill.MapDataId)) { - killsByMap.set(kill.MapDataId, []); - } - killsByMap.get(kill.MapDataId)!.push(kill); - } - } - - for (const rez of allRezzes) { - if (rez.MapDataId) { - if (!rezzesByMap.has(rez.MapDataId)) { - rezzesByMap.set(rez.MapDataId, []); - } - rezzesByMap.get(rez.MapDataId)!.push(rez); - } - } - - for (const ult of allUltimates) { - if (ult.MapDataId) { - if (!ultsByMap.has(ult.MapDataId)) { - ultsByMap.set(ult.MapDataId, []); - } - ultsByMap.get(ult.MapDataId)!.push(ult); - } - } - - // Accumulate per-hero scenario counts - const heroStats = new Map< - string, - { - uncontestedOurs: ScenarioStats; - uncontestedTheirs: ScenarioStats; - mirrorOursFirst: ScenarioStats; - mirrorTheirsFirst: ScenarioStats; - totalFightsAnalyzed: number; - } - >(); - - function getOrCreateHero(hero: string) { - if (!heroStats.has(hero)) { - heroStats.set(hero, { - uncontestedOurs: emptyScenario(), - uncontestedTheirs: emptyScenario(), - mirrorOursFirst: emptyScenario(), - mirrorTheirsFirst: emptyScenario(), - totalFightsAnalyzed: 0, - }); - } - return heroStats.get(hero)!; - } - - function incrementScenario(scenario: ScenarioStats, won: boolean) { - scenario.fights++; - if (won) { - scenario.wins++; - } else { - scenario.losses++; - } - } - - for (const mapDataId of mapDataIds) { - const ourTeamName = teamNameByMapId.get(mapDataId); - if (!ourTeamName) continue; - - const kills = killsByMap.get(mapDataId) ?? []; - const rezzes = rezzesByMap.get(mapDataId) ?? []; - const ults = ultsByMap.get(mapDataId) ?? []; - - if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) { - continue; - } - - // Build combined event array - const events: FightEvent[] = [ - ...kills.map((k) => ({ - event_type: k.event_type, - match_time: k.match_time, - attacker_team: k.attacker_team, - attacker_hero: k.attacker_hero, - victim_team: k.victim_team, - })), - ...rezzes.map((rez) => ({ - event_type: "mercy_rez" as const, - match_time: rez.match_time, - attacker_team: rez.resurrecter_team, - attacker_hero: rez.resurrecter_hero, - victim_team: rez.resurrectee_team, - })), - ...ults.map((ult) => ({ - event_type: "ultimate_start" as const, - match_time: ult.match_time, - attacker_team: ult.player_team, - attacker_hero: ult.player_hero, - })), - ]; - - events.sort((a, b) => a.match_time - b.match_time); - - // Group into fights using 15s gap rule - const fights: Fight[] = []; - let currentFight: Fight | null = null; - - for (const event of events) { - if (!currentFight || event.match_time - currentFight.end > 15) { - currentFight = { - events: [event], - start: event.match_time, - end: event.match_time, - }; - fights.push(currentFight); - } else { - currentFight.events.push(event); - currentFight.end = event.match_time; - } - } - - // Analyze each fight - for (const fight of fights) { - // Determine winner by kill differential - let ourKills = 0; - let enemyKills = 0; - - for (const event of fight.events) { - if (event.event_type === "mercy_rez") { - if (event.victim_team === ourTeamName) { - enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } - } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } - } - } - - const won = ourKills > enemyKills; - - // Collect ultimate_start events and group by hero - const ultEvents = fight.events.filter( - (e) => e.event_type === "ultimate_start" - ); - - // Build per-hero map: { ourUses, theirUses } - const heroUltMap = new Map< - string, - { ourUses: FightEvent[]; theirUses: FightEvent[] } - >(); - - for (const ult of ultEvents) { - const hero = ult.attacker_hero; - if (!heroUltMap.has(hero)) { - heroUltMap.set(hero, { ourUses: [], theirUses: [] }); - } - const entry = heroUltMap.get(hero)!; - if (ult.attacker_team === ourTeamName) { - entry.ourUses.push(ult); - } else { - entry.theirUses.push(ult); - } - } - - // Classify each hero's scenario - for (const [hero, { ourUses, theirUses }] of heroUltMap) { - const stats = getOrCreateHero(hero); - stats.totalFightsAnalyzed++; - - if (ourUses.length > 0 && theirUses.length === 0) { - incrementScenario(stats.uncontestedOurs, won); - } else if (ourUses.length === 0 && theirUses.length > 0) { - incrementScenario(stats.uncontestedTheirs, won); - } else if (ourUses.length > 0 && theirUses.length > 0) { - const ourEarliest = Math.min(...ourUses.map((u) => u.match_time)); - const theirEarliest = Math.min(...theirUses.map((u) => u.match_time)); - if (ourEarliest <= theirEarliest) { - incrementScenario(stats.mirrorOursFirst, won); - } else { - incrementScenario(stats.mirrorTheirsFirst, won); - } - } - } - } - } - - // Build result - const byHero: Record = {}; - for (const [hero, stats] of heroStats) { - if (stats.totalFightsAnalyzed < 1) continue; - byHero[hero] = { - hero, - totalFightsAnalyzed: stats.totalFightsAnalyzed, - scenarios: { - uncontestedOurs: finalizeScenario(stats.uncontestedOurs), - uncontestedTheirs: finalizeScenario(stats.uncontestedTheirs), - mirrorOursFirst: finalizeScenario(stats.mirrorOursFirst), - mirrorTheirsFirst: finalizeScenario(stats.mirrorTheirsFirst), - }, - }; - } - - const availableHeroes = Object.values(byHero) - .sort((a, b) => b.totalFightsAnalyzed - a.totalFightsAnalyzed) - .map((h) => h.hero); - - return { byHero, availableHeroes }; -} - -async function getTeamUltImpactUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); - return processUltImpactAnalysis(sharedData); -} - -export const getTeamUltImpact = cache(getTeamUltImpactUncached); diff --git a/src/data/team-ult-stats-dto.ts b/src/data/team-ult-stats-dto.ts deleted file mode 100644 index 27d0dedd3..000000000 --- a/src/data/team-ult-stats-dto.ts +++ /dev/null @@ -1,456 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import type { Fight } from "@/lib/utils"; -import { - groupEventsIntoFights, - mercyRezToKillEvent, - ultimateStartToKillEvent, -} from "@/lib/utils"; -import type { RoleName, SubroleName } from "@/types/heroes"; -import { - getHeroRole, - ROLE_SUBROLES, - SUBROLE_DISPLAY_NAMES, -} from "@/types/heroes"; -import type { Kill } from "@prisma/client"; -import { cache } from "react"; -import type { SubroleUltTiming } from "./scrim-overview-dto"; -import { - assignPlayersToSubroles, - type PlayerUltSummary, -} from "./scrim-overview-dto"; -import type { ExtendedTeamData, TeamDateRange } from "./team-shared-core"; -import { findTeamNameForMapInMemory } from "./team-shared-core"; -import { getExtendedTeamData } from "./team-shared-data"; - -export type TeamUltRoleBreakdown = { - role: RoleName; - count: number; - percentage: number; - subroleTimings: SubroleUltTiming[]; -}; - -export type PlayerUltRanking = { - playerName: string; - primaryHero: string; - totalUltsUsed: number; - mapsPlayed: number; - ultsPerMap: number; - topFightOpeningHero: string | null; - fightOpeningCount: number; -}; - -export type FightOpeningHero = { - hero: string; - count: number; -}; - -export type TeamUltStats = { - totalUltsUsed: number; - totalUltsEarned: number; - totalMaps: number; - ultsPerMap: number; - - avgChargeTime: number; - avgHoldTime: number; - - fightInitiationRate: number; - fightInitiationCount: number; - totalFightsWithUlts: number; - topFightOpeningHeroes: FightOpeningHero[]; - - roleBreakdown: TeamUltRoleBreakdown[]; - - playerRankings: PlayerUltRanking[]; -}; - -function emptyTeamUltStats(): TeamUltStats { - return { - totalUltsUsed: 0, - totalUltsEarned: 0, - totalMaps: 0, - ultsPerMap: 0, - avgChargeTime: 0, - avgHoldTime: 0, - fightInitiationRate: 0, - fightInitiationCount: 0, - totalFightsWithUlts: 0, - topFightOpeningHeroes: [], - roleBreakdown: [], - playerRankings: [], - }; -} - -function primaryHeroForPlayer(entry: PlayerUltSummary): string { - let bestHero = ""; - let bestCount = 0; - for (const [hero, count] of entry.heroCountMap) { - if (count > bestCount) { - bestCount = count; - bestHero = hero; - } - } - return bestHero; -} - -type UltStartRecord = { - player_team: string; - player_name: string; - player_hero: string; - match_time: number; - MapDataId: number | null; -}; - -type SubroleTimingAccum = { - count: number; - initiation: number; - midfight: number; - late: number; -}; - -function createSubroleTimingMap() { - const roles: RoleName[] = ["Tank", "Damage", "Support"]; - const map = new Map>(); - for (const role of roles) { - const inner = new Map(); - for (const sr of ROLE_SUBROLES[role]) { - inner.set(sr, { count: 0, initiation: 0, midfight: 0, late: 0 }); - } - map.set(role, inner); - } - return map; -} - -function extractTimings( - timingMap: Map>, - role: RoleName -): SubroleUltTiming[] { - const result: SubroleUltTiming[] = []; - const inner = timingMap.get(role)!; - for (const sr of ROLE_SUBROLES[role]) { - const accum = inner.get(sr)!; - if (accum.count > 0) { - result.push({ - subrole: SUBROLE_DISPLAY_NAMES[sr], - count: accum.count, - initiation: accum.initiation, - midfight: accum.midfight, - late: accum.late, - }); - } - } - return result; -} - -async function getTeamUltStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); - - if (sharedData.mapDataIds.length === 0) { - return emptyTeamUltStats(); - } - - const calculatedStats = await prisma.calculatedStat.findMany({ - where: { - MapDataId: { in: sharedData.mapDataIds }, - stat: { in: ["AVERAGE_ULT_CHARGE_TIME", "AVERAGE_TIME_TO_USE_ULT"] }, - }, - }); - - return processTeamUltStats(sharedData, calculatedStats); -} - -type CalculatedStatRow = { - playerName: string; - stat: string; - value: number; -}; - -function processTeamUltStats( - sharedData: ExtendedTeamData, - calculatedStats: CalculatedStatRow[] -): TeamUltStats { - const { - teamRosterSet, - mapDataIds, - allPlayerStats, - allKills, - allRezzes, - allUltimates, - } = sharedData; - - if (mapDataIds.length === 0) { - return emptyTeamUltStats(); - } - - const roles: RoleName[] = ["Tank", "Damage", "Support"]; - - const teamNameByMapId = new Map(); - for (const mapDataId of mapDataIds) { - const teamName = findTeamNameForMapInMemory( - mapDataId, - allPlayerStats, - teamRosterSet - ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } - } - - // Aggregate ults earned/used from PlayerStat - let totalUltsEarned = 0; - for (const stat of allPlayerStats) { - if (!stat.MapDataId) continue; - const ourTeam = teamNameByMapId.get(stat.MapDataId); - if (ourTeam && stat.player_team === ourTeam) { - totalUltsEarned += stat.ultimates_earned; - } - } - - // Group events by map - const killsByMap = new Map(); - const ultsByMap = new Map(); - - for (const kill of allKills) { - if (kill.MapDataId) { - if (!killsByMap.has(kill.MapDataId)) killsByMap.set(kill.MapDataId, []); - killsByMap.get(kill.MapDataId)!.push(kill); - } - } - - for (const rez of allRezzes) { - if (rez.MapDataId) { - if (!killsByMap.has(rez.MapDataId)) killsByMap.set(rez.MapDataId, []); - killsByMap.get(rez.MapDataId)!.push(mercyRezToKillEvent(rez)); - } - } - - for (const ult of allUltimates) { - if (ult.MapDataId) { - if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); - ultsByMap.get(ult.MapDataId)!.push(ult); - } - } - - // Per-player tracking - const ourPlayerUltCounts = new Map(); - const playerMapSets = new Map>(); - const playerFightOpenings = new Map>(); - - function trackPlayerUlt(playerName: string, hero: string, mapDataId: number) { - let entry = ourPlayerUltCounts.get(playerName); - if (!entry) { - entry = { heroCountMap: new Map(), totalCount: 0 }; - ourPlayerUltCounts.set(playerName, entry); - } - entry.totalCount++; - entry.heroCountMap.set(hero, (entry.heroCountMap.get(hero) ?? 0) + 1); - - if (!playerMapSets.has(playerName)) { - playerMapSets.set(playerName, new Set()); - } - playerMapSets.get(playerName)!.add(mapDataId); - } - - // Role counting - const ultsByRole: Record = { - Tank: 0, - Damage: 0, - Support: 0, - }; - let totalOurUltsUsed = 0; - - // Fight initiation tracking - const fightOpeningHeroCounts = new Map(); - let fightInitiationCount = 0; - let totalFightsWithUlts = 0; - - // Subrole timing - const subroleTimingByRole = createSubroleTimingMap(); - - // Pre-compute player ult counts for subrole assignment - for (const [mapId, ults] of ultsByMap) { - const ourTeamName = teamNameByMapId.get(mapId); - if (!ourTeamName) continue; - for (const ult of ults) { - if (ult.player_team === ourTeamName) { - trackPlayerUlt(ult.player_name, ult.player_hero, mapId); - } - } - } - - const ourSubroleAssignment = assignPlayersToSubroles(ourPlayerUltCounts); - const ourPlayerSubrole = new Map(); - for (const [sr, candidate] of ourSubroleAssignment) { - ourPlayerSubrole.set(candidate.playerName, sr); - } - - // Process each map: count ults by role, classify timing, track fight openings - for (const mapId of mapDataIds) { - const ourTeamName = teamNameByMapId.get(mapId); - if (!ourTeamName) continue; - - const mapUlts = ultsByMap.get(mapId) ?? []; - const mapKills = killsByMap.get(mapId) ?? []; - - for (const ult of mapUlts) { - if (ult.player_team === ourTeamName) { - const role = getHeroRole(ult.player_hero); - ultsByRole[role]++; - totalOurUltsUsed++; - } - } - - if (mapKills.length === 0 && mapUlts.length === 0) continue; - - // Build combined event array for fight grouping - const fightEvents: Kill[] = [ - ...mapKills, - ...mapUlts.map(ultimateStartToKillEvent), - ]; - - fightEvents.sort((a, b) => a.match_time - b.match_time); - const fights: Fight[] = groupEventsIntoFights(fightEvents); - - for (const fight of fights) { - const fightUlts = mapUlts.filter( - (u) => u.match_time >= fight.start && u.match_time <= fight.end + 15 - ); - - if (fightUlts.length === 0) continue; - - totalFightsWithUlts++; - const opener = fightUlts[0]; - if (opener.player_team === ourTeamName) { - fightInitiationCount++; - fightOpeningHeroCounts.set( - opener.player_hero, - (fightOpeningHeroCounts.get(opener.player_hero) ?? 0) + 1 - ); - - if (!playerFightOpenings.has(opener.player_name)) { - playerFightOpenings.set(opener.player_name, new Map()); - } - const heroMap = playerFightOpenings.get(opener.player_name)!; - heroMap.set( - opener.player_hero, - (heroMap.get(opener.player_hero) ?? 0) + 1 - ); - } - - // Classify ult timing within fight - const fightDuration = fight.end + 15 - fight.start; - const thirdDuration = fightDuration / 3; - - for (const ult of fightUlts) { - if (ult.player_team !== ourTeamName) continue; - const subrole = ourPlayerSubrole.get(ult.player_name); - if (!subrole) continue; - const role = getHeroRole(ult.player_hero); - const accum = subroleTimingByRole.get(role)?.get(subrole); - if (!accum) continue; - - accum.count++; - const elapsed = ult.match_time - fight.start; - if (fightDuration <= 0 || elapsed < thirdDuration) { - accum.initiation++; - } else if (elapsed < thirdDuration * 2) { - accum.midfight++; - } else { - accum.late++; - } - } - } - } - - // Build role breakdown - const roleBreakdown: TeamUltRoleBreakdown[] = roles.map((role) => ({ - role, - count: ultsByRole[role], - percentage: - totalOurUltsUsed > 0 ? (ultsByRole[role] / totalOurUltsUsed) * 100 : 0, - subroleTimings: extractTimings(subroleTimingByRole, role), - })); - - // Build top fight-opening heroes (sorted by count, top 5) - const topFightOpeningHeroes: FightOpeningHero[] = Array.from( - fightOpeningHeroCounts.entries() - ) - .map(([hero, count]) => ({ hero, count })) - .sort((a, b) => b.count - a.count) - .slice(0, 10); - - // Build player rankings - const playerRankings: PlayerUltRanking[] = []; - for (const [playerName, entry] of ourPlayerUltCounts) { - const hero = primaryHeroForPlayer(entry); - const mapsPlayed = playerMapSets.get(playerName)?.size ?? 0; - - let topFightOpeningHero: string | null = null; - let fightOpeningCount = 0; - const openings = playerFightOpenings.get(playerName); - if (openings) { - for (const [h, count] of openings) { - if (count > fightOpeningCount) { - fightOpeningCount = count; - topFightOpeningHero = h; - } - } - } - - playerRankings.push({ - playerName, - primaryHero: hero, - totalUltsUsed: entry.totalCount, - mapsPlayed, - ultsPerMap: mapsPlayed > 0 ? entry.totalCount / mapsPlayed : 0, - topFightOpeningHero, - fightOpeningCount, - }); - } - playerRankings.sort((a, b) => b.totalUltsUsed - a.totalUltsUsed); - - // Compute charge/hold times from CalculatedStat - const chargeTimeValues: number[] = []; - const holdTimeValues: number[] = []; - for (const cs of calculatedStats) { - if (!teamRosterSet.has(cs.playerName) || cs.value <= 0) continue; - if (cs.stat === "AVERAGE_ULT_CHARGE_TIME") chargeTimeValues.push(cs.value); - else if (cs.stat === "AVERAGE_TIME_TO_USE_ULT") - holdTimeValues.push(cs.value); - } - - const avgChargeTime = - chargeTimeValues.length > 0 - ? chargeTimeValues.reduce((a, b) => a + b, 0) / chargeTimeValues.length - : 0; - const avgHoldTime = - holdTimeValues.length > 0 - ? holdTimeValues.reduce((a, b) => a + b, 0) / holdTimeValues.length - : 0; - - const totalMaps = mapDataIds.length; - - return { - totalUltsUsed: totalOurUltsUsed, - totalUltsEarned, - totalMaps, - ultsPerMap: totalMaps > 0 ? totalOurUltsUsed / totalMaps : 0, - avgChargeTime, - avgHoldTime, - fightInitiationRate: - totalFightsWithUlts > 0 - ? (fightInitiationCount / totalFightsWithUlts) * 100 - : 0, - fightInitiationCount, - totalFightsWithUlts, - topFightOpeningHeroes, - roleBreakdown, - playerRankings, - }; -} - -export const getTeamUltStats = cache(getTeamUltStatsUncached); diff --git a/src/data/tempo-dto.ts b/src/data/tempo-dto.ts deleted file mode 100644 index a61e3dae4..000000000 --- a/src/data/tempo-dto.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { resolveMapDataId } from "@/lib/map-data-resolver"; -import prisma from "@/lib/prisma"; -import { - groupEventsIntoFights, - mercyRezToKillEvent, - type Fight, -} from "@/lib/utils"; - -export type TempoDataPoint = { - time: number; - team1: number; - team2: number; -}; - -export type UltPin = { - time: number; - hero: string; - playerName: string; - team: "team1" | "team2"; -}; - -export type FightBoundary = { - start: number; - end: number; - fightNumber: number; -}; - -export type KillPin = { - time: number; - hero: string; - playerName: string; - victimHero: string; - victimName: string; - team: "team1" | "team2"; -}; - -export type TempoChartData = { - combinedSeries: TempoDataPoint[]; - killsSeries: TempoDataPoint[]; - ultsSeries: TempoDataPoint[]; - ultPins: UltPin[]; - killPins: KillPin[]; - fightBoundaries: FightBoundary[]; - matchStartTime: number; - matchEndTime: number; - team1Name: string; - team2Name: string; -}; - -type WeightedEvent = { time: number; weight: number }; - -const SIGMA = 5; // bandwidth in seconds -const SAMPLE_INTERVAL = 0.5; // sample every 0.5s -const TWO_SIGMA_SQ = 2 * SIGMA * SIGMA; - -function gaussianKDE( - events: WeightedEvent[], - sampleStart: number, - sampleEnd: number -): number[] { - const n = Math.ceil((sampleEnd - sampleStart) / SAMPLE_INTERVAL) + 1; - const values = new Array(n).fill(0); - - for (const e of events) { - // Only compute contributions within 3*sigma of the event - const lo = Math.max( - 0, - Math.floor((e.time - 3 * SIGMA - sampleStart) / SAMPLE_INTERVAL) - ); - const hi = Math.min( - n - 1, - Math.ceil((e.time + 3 * SIGMA - sampleStart) / SAMPLE_INTERVAL) - ); - for (let i = lo; i <= hi; i++) { - const t = sampleStart + i * SAMPLE_INTERVAL; - const diff = t - e.time; - values[i] += e.weight * Math.exp(-(diff * diff) / TWO_SIGMA_SQ); - } - } - - return values; -} - -export function computeTempoSeries( - kills: { match_time: number; attacker_team: string }[], - ultStarts: { match_time: number; player_team: string }[], - team1Name: string, - matchStart: number, - matchEnd: number, - mode: "combined" | "kills" | "ults" -): TempoDataPoint[] { - const killWeight = 1.0; - const ultWeight = 0.6; - - const team1Events: WeightedEvent[] = []; - const team2Events: WeightedEvent[] = []; - - if (mode === "combined" || mode === "kills") { - for (const k of kills) { - const entry = { time: k.match_time, weight: killWeight }; - if (k.attacker_team === team1Name) { - team1Events.push(entry); - } else { - team2Events.push(entry); - } - } - } - - if (mode === "combined" || mode === "ults") { - for (const u of ultStarts) { - const entry = { time: u.match_time, weight: ultWeight }; - if (u.player_team === team1Name) { - team1Events.push(entry); - } else { - team2Events.push(entry); - } - } - } - - const t1Values = gaussianKDE(team1Events, matchStart, matchEnd); - const t2Values = gaussianKDE(team2Events, matchStart, matchEnd); - - // Normalize peak to 1.0 across both teams - let maxVal = 0; - for (let i = 0; i < t1Values.length; i++) { - maxVal = Math.max(maxVal, t1Values[i], t2Values[i]); - } - if (maxVal === 0) maxVal = 1; - - const series: TempoDataPoint[] = []; - for (let i = 0; i < t1Values.length; i++) { - series.push({ - time: matchStart + i * SAMPLE_INTERVAL, - team1: t1Values[i] / maxVal, - team2: t2Values[i] / maxVal, - }); - } - - return series; -} - -export function fightsToBoundaries(fights: Fight[]): FightBoundary[] { - return fights.map((f, i) => ({ - start: f.start, - end: f.end, - fightNumber: i + 1, - })); -} - -export function tempoPointsToSvgPath( - points: { x: number; y: number }[] -): string { - if (points.length === 0) return ""; - if (points.length === 1) return `M${points[0].x},${points[0].y}`; - if (points.length === 2) { - return `M${points[0].x},${points[0].y}L${points[1].x},${points[1].y}`; - } - - const alpha = 0.5; // Catmull-Rom tension - - let d = `M${points[0].x},${points[0].y}`; - - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[Math.max(0, i - 1)]; - const p1 = points[i]; - const p2 = points[i + 1]; - const p3 = points[Math.min(points.length - 1, i + 2)]; - - const cp1x = p1.x + (p2.x - p0.x) / (6 / alpha); - const cp1y = p1.y + (p2.y - p0.y) / (6 / alpha); - const cp2x = p2.x - (p3.x - p1.x) / (6 / alpha); - const cp2y = p2.y - (p3.y - p1.y) / (6 / alpha); - - d += `C${cp1x},${cp1y},${cp2x},${cp2y},${p2.x},${p2.y}`; - } - - return d; -} - -export async function getTempoChartData( - mapId: number -): Promise { - const mapDataId = await resolveMapDataId(mapId); - const [kills, ultStarts, matchStart, matchEnd, mercyRezzes] = - await Promise.all([ - prisma.kill.findMany({ where: { MapDataId: mapDataId } }), - prisma.ultimateStart.findMany({ where: { MapDataId: mapDataId } }), - prisma.matchStart.findFirst({ where: { MapDataId: mapDataId } }), - prisma.matchEnd.findFirst({ where: { MapDataId: mapDataId } }), - prisma.mercyRez.findMany({ where: { MapDataId: mapDataId } }), - ]); - - if (!matchStart || !matchEnd) return null; - if (kills.length < 3) return null; - - const team1Name = matchStart.team_1_name; - const team2Name = matchStart.team_2_name; - const startTime = matchStart.match_time; - const endTime = matchEnd.match_time; - - const allKillEvents = [ - ...kills, - ...mercyRezzes.map(mercyRezToKillEvent), - ].sort((a, b) => a.match_time - b.match_time); - const fights = groupEventsIntoFights(allKillEvents); - const fightBoundaries = fightsToBoundaries(fights); - - const combinedSeries = computeTempoSeries( - kills, - ultStarts, - team1Name, - startTime, - endTime, - "combined" - ); - const killsSeries = computeTempoSeries( - kills, - ultStarts, - team1Name, - startTime, - endTime, - "kills" - ); - const ultsSeries = computeTempoSeries( - kills, - ultStarts, - team1Name, - startTime, - endTime, - "ults" - ); - - const ultPins: UltPin[] = ultStarts.map((u) => ({ - time: u.match_time, - hero: u.player_hero, - playerName: u.player_name, - team: u.player_team === team1Name ? "team1" : "team2", - })); - - const killPins: KillPin[] = kills.map((k) => ({ - time: k.match_time, - hero: k.attacker_hero, - playerName: k.attacker_name, - victimHero: k.victim_hero, - victimName: k.victim_name, - team: k.attacker_team === team1Name ? "team1" : "team2", - })); - - return { - combinedSeries, - killsSeries, - ultsSeries, - ultPins, - killPins, - fightBoundaries, - matchStartTime: startTime, - matchEndTime: endTime, - team1Name, - team2Name, - }; -} diff --git a/src/data/tournament-dto.ts b/src/data/tournament-dto.ts deleted file mode 100644 index 05c7cf7a1..000000000 --- a/src/data/tournament-dto.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - calculateRRStandings, - type TeamStanding, -} from "@/lib/tournaments/round-robin"; -import prisma from "@/lib/prisma"; -import { cache } from "react"; - -async function getTournamentFn(id: number) { - return await prisma.tournament.findUnique({ - where: { id }, - include: { - teams: { - include: { team: { select: { id: true, name: true, image: true } } }, - orderBy: { seed: "asc" }, - }, - rounds: { orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }] }, - matches: { - include: { - team1: true, - team2: true, - winner: true, - round: true, - maps: { include: { map: true }, orderBy: { gameNumber: "asc" } }, - }, - orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], - }, - }, - }); -} - -export const getTournament = cache(getTournamentFn); - -async function getUserTournamentsFn(userId: string) { - return await prisma.tournament.findMany({ - where: { - OR: [ - { creatorId: userId }, - { - teams: { - some: { team: { users: { some: { id: userId } } } }, - }, - }, - ], - }, - include: { - teams: { select: { name: true }, orderBy: { seed: "asc" } }, - _count: { select: { matches: true } }, - }, - orderBy: { createdAt: "desc" }, - }); -} - -export const getUserTournaments = cache(getUserTournamentsFn); - -async function getTournamentMatchFn(matchId: number) { - return await prisma.tournamentMatch.findUnique({ - where: { id: matchId }, - include: { - tournament: true, - round: true, - team1: { - include: { team: { select: { id: true, name: true, image: true } } }, - }, - team2: { - include: { team: { select: { id: true, name: true, image: true } } }, - }, - winner: true, - maps: { - include: { - map: { - include: { - mapData: { - include: { - match_start: true, - match_end: true, - round_end: true, - HeroBan: true, - }, - }, - }, - }, - }, - orderBy: { gameNumber: "asc" }, - }, - }, - }); -} - -export const getTournamentMatch = cache(getTournamentMatchFn); - -async function getTournamentBracketFn(tournamentId: number) { - return await prisma.tournament.findUnique({ - where: { id: tournamentId }, - include: { - teams: { orderBy: { seed: "asc" } }, - rounds: { orderBy: [{ bracket: "asc" }, { roundNumber: "asc" }] }, - matches: { - include: { - team1: { select: { id: true, name: true, seed: true } }, - team2: { select: { id: true, name: true, seed: true } }, - winner: { select: { id: true, name: true } }, - round: { - select: { roundNumber: true, roundName: true, bracket: true }, - }, - }, - orderBy: [{ roundId: "asc" }, { bracketPosition: "asc" }], - }, - }, - }); -} - -export const getTournamentBracket = cache(getTournamentBracketFn); - -async function getRRStandingsFn(tournamentId: number): Promise { - return calculateRRStandings(tournamentId); -} - -export const getRRStandings = cache(getRRStandingsFn); diff --git a/src/data/user-dto.tsx b/src/data/user-dto.tsx deleted file mode 100644 index db42322dc..000000000 --- a/src/data/user-dto.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import "server-only"; - -import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; -import { cache } from "react"; - -async function getUserFn(email: string | undefined) { - if (!email) return null; - - const user = await prisma.user.findFirst({ where: { email } }); - if (!user) return null; - - return user; -} - -/** - * Get a user from the database by email address. - * This function is cached for performance. - * - * @param email - The email address of the user to get. - * @returns The user with the specified email address or null if no user was found. - */ -export const getUser = cache(getUserFn); - -async function getTeamsWithPermsFn(email: string | undefined) { - const user = await getUser(email); - - const teams = await prisma.team.findMany({ - where: { - OR: [ - { ownerId: user?.id }, - { - users: { - some: { - id: user?.id, - role: { in: [$Enums.UserRole.MANAGER, $Enums.UserRole.ADMIN] }, - }, - }, - }, - { managers: { some: { userId: user?.id } } }, - ], - id: { not: 0 }, - }, - }); - - return teams; -} - -/** - * Get all teams that the user has permission to modify. - * This function is cached for performance. - * - * @param userId - The user ID of the user to get teams for. - * @returns The teams that the user has permission to view. - */ -export const getTeamsWithPerms = cache(getTeamsWithPermsFn); - -async function getAppSettingsFn(email: string | undefined) { - const user = await getUser(email); - if (!user) return null; - - const appSettings = await prisma.appSettings.findFirst({ - where: { userId: user?.id }, - }); - - return appSettings; -} - -/** - * Get the app settings for a user. - * This function is cached for performance. - * - * @param email - The email address of the user to get app settings for. - * @returns The app settings for the user. - */ -export const getAppSettings = cache(getAppSettingsFn); From eea5b39eff8d70f61c6f7a7c0f6307323e573946 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 17:52:24 -0400 Subject: [PATCH 33/42] Update test suite to mock server-side functions --- test/__mocks__/next-axiom.ts | 9 +++++++++ test/__mocks__/server-only.ts | 2 ++ test/setup.ts | 36 +++++++++++++++++++++++++++++++++++ vitest.config.ts | 7 +++++++ 4 files changed, 54 insertions(+) create mode 100644 test/__mocks__/next-axiom.ts create mode 100644 test/__mocks__/server-only.ts create mode 100644 test/setup.ts diff --git a/test/__mocks__/next-axiom.ts b/test/__mocks__/next-axiom.ts new file mode 100644 index 000000000..a06a59149 --- /dev/null +++ b/test/__mocks__/next-axiom.ts @@ -0,0 +1,9 @@ +// Mock for next-axiom in test environment +const noop = (..._args: unknown[]) => {}; + +export const log = { + debug: noop, + info: noop, + warn: noop, + error: noop, +}; diff --git a/test/__mocks__/server-only.ts b/test/__mocks__/server-only.ts new file mode 100644 index 000000000..4f50e11e8 --- /dev/null +++ b/test/__mocks__/server-only.ts @@ -0,0 +1,2 @@ +// Mock for server-only package in test environment +export {}; diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 000000000..b7b852157 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,36 @@ +import { vi } from "vitest"; + +// Mock server-side modules that pull in next/server or other +// Node-only Next.js internals that Vitest cannot resolve. + +vi.mock("@axiomhq/nextjs", () => ({ + createOnRequestError: () => () => {}, + createAxiomRouteHandler: () => () => {}, + nextJsFormatters: () => ({}), + transformMiddlewareRequest: () => ({}), +})); + +vi.mock("@/lib/logger", () => ({ + Logger: { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + }, +})); + +vi.mock("@/lib/notifications", () => ({ + notifications: { + createInAppNotification: vi.fn(), + }, +})); + +vi.mock("@/lib/calculate-stats", () => ({ + calculateStats: vi.fn(), +})); + +vi.mock("@/data/runtime", () => ({ + AppRuntime: { + runPromise: vi.fn(), + }, +})); diff --git a/vitest.config.ts b/vitest.config.ts index aab324410..d56d21bc2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,9 +2,16 @@ import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + resolve: { + alias: { + "server-only": path.resolve(__dirname, "./test/__mocks__/server-only.ts"), + "next-axiom": path.resolve(__dirname, "./test/__mocks__/next-axiom.ts"), + }, + }, test: { alias: { "@": path.resolve(__dirname, "./src"), }, + setupFiles: ["./test/setup.ts"], }, }); From 1faf0dc4418f9199ddfa61a9bee9716017d682b2 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:02:55 -0400 Subject: [PATCH 34/42] Switch import to parser client --- src/app/debug/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/debug/page.tsx b/src/app/debug/page.tsx index a9b50f7cf..5e9518a6c 100644 --- a/src/app/debug/page.tsx +++ b/src/app/debug/page.tsx @@ -10,7 +10,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Link } from "@/components/ui/link"; import { Separator } from "@/components/ui/separator"; -import { parseData } from "@/lib/parser"; +import { parseData } from "@/lib/parser-client"; import { ParserDataSchema } from "@/lib/schema"; import { cn, detectCorruptedData } from "@/lib/utils"; import { PlusCircledIcon } from "@radix-ui/react-icons"; From b881021c1683b63d9ebe87f842638d970debb494 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:06:15 -0400 Subject: [PATCH 35/42] Fix linting error --- test/__mocks__/next-axiom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/__mocks__/next-axiom.ts b/test/__mocks__/next-axiom.ts index a06a59149..09d7d962b 100644 --- a/test/__mocks__/next-axiom.ts +++ b/test/__mocks__/next-axiom.ts @@ -1,5 +1,5 @@ // Mock for next-axiom in test environment -const noop = (..._args: unknown[]) => {}; +function noop(..._args: unknown[]) {} export const log = { debug: noop, From 9fcc2d43732e9eb3cb3d7acc88469260ec78f10b Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:22:48 -0400 Subject: [PATCH 36/42] Update cache key date string handling --- src/data/team/ability-impact-service.ts | 8 +++++--- src/data/team/analytics-service.ts | 7 +++---- src/data/team/ban-impact-service.ts | 4 ++-- src/data/team/fight-stats-service.ts | 8 +++++--- src/data/team/hero-pool-service.ts | 4 ++-- src/data/team/hero-swap-service.ts | 4 ++-- src/data/team/map-mode-service.ts | 4 ++-- src/data/team/matchup-service.ts | 7 +++---- src/data/team/prediction-service.ts | 5 ++--- src/data/team/quick-wins-service.ts | 4 ++-- src/data/team/role-stats-service.ts | 13 +++++-------- src/data/team/shared-core.ts | 12 ++++++++++++ src/data/team/stats-service.ts | 16 ++++++---------- src/data/team/trends-service.ts | 10 ++++------ src/data/team/ult-service.ts | 11 ++++++----- 15 files changed, 61 insertions(+), 56 deletions(-) diff --git a/src/data/team/ability-impact-service.ts b/src/data/team/ability-impact-service.ts index f848c722c..1e0803291 100644 --- a/src/data/team/ability-impact-service.ts +++ b/src/data/team/ability-impact-service.ts @@ -12,7 +12,10 @@ import { } from "effect"; import { TeamQueryError } from "./errors"; import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; -import { findTeamNameForMapInMemory } from "./shared-core"; +import { + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { TeamSharedDataService, @@ -459,8 +462,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamAbilityImpact(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/analytics-service.ts b/src/data/team/analytics-service.ts index d64a6d59c..262b4780d 100644 --- a/src/data/team/analytics-service.ts +++ b/src/data/team/analytics-service.ts @@ -22,6 +22,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { TeamSharedDataService, @@ -708,8 +709,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getPlayerMapPerformanceMatrix(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -724,8 +724,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getHeroPickrateRawData(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/ban-impact-service.ts b/src/data/team/ban-impact-service.ts index b09dfdec8..7986735b9 100644 --- a/src/data/team/ban-impact-service.ts +++ b/src/data/team/ban-impact-service.ts @@ -17,6 +17,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -375,8 +376,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamBanImpactAnalysis(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/fight-stats-service.ts b/src/data/team/fight-stats-service.ts index 32d5de1fb..4145c4b30 100644 --- a/src/data/team/fight-stats-service.ts +++ b/src/data/team/fight-stats-service.ts @@ -11,7 +11,10 @@ import { } from "effect"; import type { TeamQueryError } from "./errors"; import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; -import { findTeamNameForMapInMemory } from "./shared-core"; +import { + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { TeamSharedDataService, @@ -437,8 +440,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamFightStats(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/hero-pool-service.ts b/src/data/team/hero-pool-service.ts index 02f37abfc..bbc834767 100644 --- a/src/data/team/hero-pool-service.ts +++ b/src/data/team/hero-pool-service.ts @@ -21,6 +21,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -678,8 +679,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getHeroPoolRawData(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/hero-swap-service.ts b/src/data/team/hero-swap-service.ts index 8b1ba6a52..b7b9416df 100644 --- a/src/data/team/hero-swap-service.ts +++ b/src/data/team/hero-swap-service.ts @@ -18,6 +18,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -642,8 +643,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamHeroSwapStats(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/map-mode-service.ts b/src/data/team/map-mode-service.ts index 9701c42b8..4d1b9f33b 100644 --- a/src/data/team/map-mode-service.ts +++ b/src/data/team/map-mode-service.ts @@ -20,6 +20,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { TeamSharedDataService, @@ -366,8 +367,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getMapModePerformance(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/matchup-service.ts b/src/data/team/matchup-service.ts index cf3c8f466..16ea9a1b8 100644 --- a/src/data/team/matchup-service.ts +++ b/src/data/team/matchup-service.ts @@ -19,6 +19,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { TeamSharedDataService, @@ -423,8 +424,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getMatchupWinrateData(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -439,8 +439,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getEnemyHeroAnalysis(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/prediction-service.ts b/src/data/team/prediction-service.ts index d8a266670..e3c0b96c6 100644 --- a/src/data/team/prediction-service.ts +++ b/src/data/team/prediction-service.ts @@ -27,7 +27,7 @@ import { TeamRoleStatsService, TeamRoleStatsServiceLive, } from "./role-stats-service"; -import type { TeamDateRange } from "./shared-core"; +import { type TeamDateRange, parseDateRangeFromCacheKey } from "./shared-core"; import { TeamStatsService, TeamStatsServiceLive } from "./stats-service"; const predictionQuerySuccessTotal = Metric.counter( @@ -251,8 +251,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getSimulatorContext(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/quick-wins-service.ts b/src/data/team/quick-wins-service.ts index 4efe8736e..e080bf462 100644 --- a/src/data/team/quick-wins-service.ts +++ b/src/data/team/quick-wins-service.ts @@ -23,6 +23,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -433,8 +434,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getQuickWinsStats(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/role-stats-service.ts b/src/data/team/role-stats-service.ts index 0b73e7f6e..e067d4f28 100644 --- a/src/data/team/role-stats-service.ts +++ b/src/data/team/role-stats-service.ts @@ -19,6 +19,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -791,8 +792,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getRolePerformanceStats(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -807,8 +807,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getRoleBalanceAnalysis(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -823,8 +822,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getBestRoleTrios(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -839,8 +837,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getRoleWinratesByMap(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/shared-core.ts b/src/data/team/shared-core.ts index 5ed5500ea..c89743e21 100644 --- a/src/data/team/shared-core.ts +++ b/src/data/team/shared-core.ts @@ -57,6 +57,18 @@ export type TeamDateRange = { to: Date; }; +/** + * Reconstruct a `TeamDateRange` from a JSON-parsed cache key. + * `JSON.parse` turns Date values into strings, so this converts them back. + */ +export function parseDateRangeFromCacheKey( + raw: string +): TeamDateRange | undefined { + const parsed = JSON.parse(raw) as { from?: string; to?: string } | undefined; + if (!parsed?.from || !parsed?.to) return undefined; + return { from: new Date(parsed.from), to: new Date(parsed.to) }; +} + export function findTeamNameForMapInMemory( mapDataId: number, allPlayerStats: { diff --git a/src/data/team/stats-service.ts b/src/data/team/stats-service.ts index 4e91b01ee..99ee72400 100644 --- a/src/data/team/stats-service.ts +++ b/src/data/team/stats-service.ts @@ -17,6 +17,7 @@ import { buildFinalRoundMap, buildMatchStartMap, buildProgressMaps, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -722,8 +723,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamWinrates(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -749,8 +749,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTopMapsByPlaytime(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -765,8 +764,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTop5MapsByPlaytime(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -781,8 +779,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getBestMapByWinrate(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -797,8 +794,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getBlindSpotMap(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/trends-service.ts b/src/data/team/trends-service.ts index 8d912d4b3..ac42b9f1c 100644 --- a/src/data/team/trends-service.ts +++ b/src/data/team/trends-service.ts @@ -19,6 +19,7 @@ import { buildMatchStartMap, buildProgressMaps, findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, } from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { @@ -629,8 +630,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamMatchResults(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -664,8 +664,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getRecentForm(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -680,8 +679,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getStreakInfo(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); diff --git a/src/data/team/ult-service.ts b/src/data/team/ult-service.ts index fa1c6ff3c..a9cbaad79 100644 --- a/src/data/team/ult-service.ts +++ b/src/data/team/ult-service.ts @@ -23,7 +23,10 @@ import { } from "effect"; import { TeamQueryError } from "./errors"; import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; -import { findTeamNameForMapInMemory } from "./shared-core"; +import { + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} from "./shared-core"; import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; import { TeamSharedDataService, @@ -756,8 +759,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamUltImpact(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); @@ -772,8 +774,7 @@ export const make = Effect.gen(function* () { key.slice(0, key.indexOf(":")), key.slice(key.indexOf(":") + 1), ]; - const dateRange = JSON.parse(rest) as TeamDateRange | undefined; - const dr = dateRange?.from ? dateRange : undefined; + const dr = parseDateRangeFromCacheKey(rest); return getTeamUltStats(Number(teamIdStr), dr).pipe( Effect.tap(() => Metric.increment(teamCacheMissTotal)) ); From 0fb2d988eb9868c16d9c1173ac734d4d6b281f2c Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:28:28 -0400 Subject: [PATCH 37/42] Update parser coordinate regex --- src/lib/parser-client.ts | 4 +++- src/lib/parser.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/parser-client.ts b/src/lib/parser-client.ts index 6199cd86a..e219bb848 100644 --- a/src/lib/parser-client.ts +++ b/src/lib/parser-client.ts @@ -144,7 +144,9 @@ export function parseCoordinate( value: unknown ): { x: number; y: number; z: number } | null { if (!value || typeof value !== "string") return null; - const match = value.trim().match(/^\(([^,]+),\s*([^,]+),\s*([^)]+)\)$/); + const match = value + .trim() + .match(/^\(([^,\s]+)\s*,\s*([^,\s]+)\s*,\s*([^)\s]+)\s*\)$/); if (!match) return null; const x = parseFloat(match[1]); const y = parseFloat(match[2]); diff --git a/src/lib/parser.ts b/src/lib/parser.ts index ba4c98739..fce6a6548 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -177,7 +177,9 @@ export function parseCoordinate( value: unknown ): { x: number; y: number; z: number } | null { if (!value || typeof value !== "string") return null; - const match = value.trim().match(/^\(([^,]+),\s*([^,]+),\s*([^)]+)\)$/); + const match = value + .trim() + .match(/^\(([^,\s]+)\s*,\s*([^,\s]+)\s*,\s*([^)\s]+)\s*\)$/); if (!match) return null; const x = parseFloat(match[1]); const y = parseFloat(match[2]); From a9e49888eca800a6e9980116bfde4ce6ba2e3d13 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:29:42 -0400 Subject: [PATCH 38/42] Update package.json version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac8f342c1..8860c758e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parsertime", - "version": "2.1.0", + "version": "3.0.0", "private": true, "scripts": { "dev": "next dev --turbopack", From fe3166dfb248eda5c17b3044ac3361d99bd923bf Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:41:52 -0400 Subject: [PATCH 39/42] Pass mapDataId to heatmap and replay tabs --- src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 3498c020b..0abb9b6f4 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -288,12 +288,12 @@ export default async function MapDashboardPage( { value: "heatmap", label: t("tabs.heatmap"), - content: , + content: , }, { value: "replay", label: t("tabs.replay"), - content: , + content: , }, ] : []), From ce278bf9245b06605b7e71b1e53784a7975fbe82 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 18:47:47 -0400 Subject: [PATCH 40/42] Fix path style for S3 client --- src/lib/r2/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/r2/index.ts b/src/lib/r2/index.ts index d9fc6d5d5..3200b9770 100644 --- a/src/lib/r2/index.ts +++ b/src/lib/r2/index.ts @@ -101,6 +101,7 @@ export const make: Effect.Effect = const s3Client = new S3Client({ region: "auto", endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, + forcePathStyle: true, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, From fdbc132d85949a8bb03ed4aa4f37d678450e9d5d Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 19:00:02 -0400 Subject: [PATCH 41/42] Replace wrong ID for killfeed calibration data --- src/components/map/killfeed.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/map/killfeed.tsx b/src/components/map/killfeed.tsx index 5e640e9c6..8d689c25d 100644 --- a/src/components/map/killfeed.tsx +++ b/src/components/map/killfeed.tsx @@ -58,7 +58,7 @@ export async function Killfeed({ ? serializeCalibrationData( await AppRuntime.runPromise( KillfeedCalibrationService.pipe( - Effect.flatMap((svc) => svc.getKillfeedCalibration(id)) + Effect.flatMap((svc) => svc.getKillfeedCalibration(mapDataId)) ) ) ) @@ -243,7 +243,7 @@ export async function Killfeed({ team2Color={team2Color} calibrationData={calibrationData} canvasImportEnabled={canvasEnabled && positionalEnabled} - mapDataId={id} + mapDataId={mapDataId} />
From 849e5344607ceb604730977aa46e843c6cacae37 Mon Sep 17 00:00:00 2001 From: Lucas Doell Date: Fri, 10 Apr 2026 19:08:50 -0400 Subject: [PATCH 42/42] Add fight to canvas to event timeline view --- src/components/map/fight-timeline.tsx | 17 ++++++++++++++++- src/components/map/import-to-canvas-link.tsx | 9 +++++++-- src/components/map/killfeed-table.tsx | 2 ++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/map/fight-timeline.tsx b/src/components/map/fight-timeline.tsx index 0cd93c64c..8d450f085 100644 --- a/src/components/map/fight-timeline.tsx +++ b/src/components/map/fight-timeline.tsx @@ -21,6 +21,7 @@ import { type SerializedCalibrationData, type UltimateSpan, } from "@/data/map/killfeed/types"; +import { ImportToCanvasLink } from "@/components/map/import-to-canvas-link"; import { useGoToReplay } from "@/components/map/map-tabs"; import { cn, toHero, toKebabCase, toTimestamp } from "@/lib/utils"; import type { Kill, RoundEnd } from "@prisma/client"; @@ -125,6 +126,8 @@ type FightTimelineProps = { t: ReturnType; tUlt: ReturnType; calibrationData?: SerializedCalibrationData; + canvasImportEnabled?: boolean; + mapDataId?: number; }; export function FightTimeline({ @@ -143,6 +146,8 @@ export function FightTimeline({ t, tUlt, calibrationData, + canvasImportEnabled, + mapDataId, }: FightTimelineProps) { const timeMin = fight.start; const timeMax = Math.max(fight.end, ...spans.map((s) => s.endTime)); @@ -220,7 +225,7 @@ export function FightTimeline({ {/* Left labels column — fight start/end markers */}
{t("fight", { num: fightIndex + 1 })} {t("start")} + {canvasImportEnabled && mapDataId != null && calibrationData && ( + + )}
; mapDataId: number; team1: string; t: ReturnType; + className?: string; }) { const router = useRouter(); const [loading, setLoading] = useState(false); @@ -132,7 +134,10 @@ export function ImportToCanvasLink({ return (
);