diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000..1f87157fc --- /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" + } + } + ] + } + } +} diff --git a/package.json b/package.json index 9fc51d6b1..8860c758e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "parsertime", - "version": "2.1.0", + "version": "3.0.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", + "prepare": "effect-language-service patch", "postinstall": "prisma generate", "start": "next start", "lint": "next typegen && oxlint", 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]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 562d0b92c..7eaa7cd14 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -2,9 +2,11 @@ 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 { 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 { ScoutingService } from "@/data/scouting"; +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,11 @@ 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,8 +32,14 @@ export default async function EditScrimPage( const [teamsWithPerms, scoutingTeamList, scoutingEnabled] = await Promise.all( [ - getTeamsWithPerms(session?.user?.email), - getScoutingTeams(), + AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => svc.getTeamsWithPerms(session?.user?.email)) + ) + ), + AppRuntime.runPromise( + ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) + ), scoutingTool(), ] ); diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 9cfd68502..0abb9b6f4 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -22,8 +22,10 @@ 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 { getUser } from "@/data/user-dto"; +import { PlayerService } from "@/data/player"; +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 @@ -120,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 }, @@ -282,12 +288,12 @@ export default async function MapDashboardPage( { value: "heatmap", label: t("tabs.heatmap"), - content: , + content: , }, { value: "replay", label: t("tabs.replay"), - content: , + content: , }, ] : []), 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..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,8 +12,10 @@ 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 { getUser } from "@/data/user-dto"; +import { PlayerService } from "@/data/player"; +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"; @@ -66,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: { @@ -78,7 +82,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/[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/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts index b49bac0af..8eea6b49f 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,11 @@ 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 +49,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/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..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-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/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..61b4b9382 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,11 @@ 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/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..68e7958a2 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 { deleteMapGroup, updateMapGroup } from "@/data/map-group-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { MapGroupService } from "@/data/map"; 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"; @@ -151,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 }, @@ -234,7 +244,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"; @@ -319,7 +331,9 @@ 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 c7527a05e..b750750be 100644 --- a/src/app/api/compare/map-groups/route.ts +++ b/src/app/api/compare/map-groups/route.ts @@ -1,5 +1,7 @@ -import { createMapGroup } from "@/data/map-group-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { MapGroupService } from "@/data/map"; 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"; @@ -222,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/maps/route.ts b/src/app/api/compare/maps/route.ts index c29e513c6..071ddf96b 100644 --- a/src/app/api/compare/maps/route.ts +++ b/src/app/api/compare/maps/route.ts @@ -1,5 +1,7 @@ -import { getAvailableMapsForComparison } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ComparisonAggregationService } from "@/data/comparison"; 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"; @@ -85,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 0d9ea2c3e..84ed2a7f8 100644 --- a/src/app/api/compare/players/route.ts +++ b/src/app/api/compare/players/route.ts @@ -1,5 +1,7 @@ -import { getTeamPlayers } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ComparisonAggregationService } from "@/data/comparison"; 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"; @@ -75,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 24c66b21a..ab1f6614b 100644 --- a/src/app/api/compare/stats/route.ts +++ b/src/app/api/compare/stats/route.ts @@ -1,5 +1,7 @@ -import { getComparisonStats } from "@/data/comparison-dto"; -import { getUser } from "@/data/user-dto"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { ComparisonAggregationService } from "@/data/comparison"; 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"; @@ -111,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/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/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..9e7151f5b 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,11 @@ 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 +89,11 @@ 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 +141,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/player/stats/route.ts b/src/app/api/player/stats/route.ts index 5a053db5f..769b32329 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,34 @@ 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/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/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/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 3e6cfee6f..7e3149939 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,11 @@ 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 +130,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/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts index d6934ea84..832c17613 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,13 @@ 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 +42,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..325890500 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,20 @@ 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/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..95a1708c6 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,13 @@ 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..8721661b1 100644 --- a/src/app/api/team/targets/route.ts +++ b/src/app/api/team/targets/route.ts @@ -1,5 +1,7 @@ -import { getRecentScrimStats } 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 { TargetsService } from "@/data/player"; 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 @@ -89,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/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts index 9650f7e71..db62776a8 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,13 @@ 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 +48,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..5bd091d4d 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,19 @@ 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 +92,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..236540882 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,9 @@ 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/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/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"; 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/layout.tsx b/src/app/layout.tsx index c51d53c84..15ce130cd 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,9 @@ 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/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx index 99401428e..2c7b0eaf5 100644 --- a/src/app/profile/[playerName]/page.tsx +++ b/src/app/profile/[playerName]/page.tsx @@ -13,19 +13,15 @@ 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, + TargetsService, type TargetProgress, -} from "@/data/targets-dto"; -import { getUser } from "@/data/user-dto"; +} from "@/data/player"; +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 +269,33 @@ 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,14 +304,21 @@ 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; 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) { @@ -316,13 +340,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/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/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/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 45250c4f9..47e4fd015 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -8,14 +8,14 @@ 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 { - getTeamStrengthRating, - getTeamStrengthPercentile, -} from "@/data/opponent-strength-dto"; -import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; -import { getScoutingTeamProfile } from "@/data/scouting-dto"; + 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"; import { auth } from "@/lib/auth"; import { resolveDataAvailability } from "@/lib/data-availability"; import { scoutingTool } from "@/lib/flags"; @@ -86,7 +86,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,21 +99,56 @@ 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), ]); 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/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/[playerName]/page.tsx b/src/app/stats/[playerName]/page.tsx index 9e99b2679..8d11c52be 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,37 @@ 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/app/stats/hero/[heroName]/page.tsx b/src/app/stats/hero/[heroName]/page.tsx index 139efc35a..20fae4be3 100644 --- a/src/app/stats/hero/[heroName]/page.tsx +++ b/src/app/stats/hero/[heroName]/page.tsx @@ -4,12 +4,10 @@ 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 { getUser } from "@/data/user-dto"; +import { HeroService } from "@/data/hero"; +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 +61,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(), @@ -142,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 cfb8301cd..ce12adf6d 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -32,41 +32,26 @@ 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 { - 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"; -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"; -import { getUser } from "@/data/user-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"; import { auth } from "@/lib/auth"; import { simulationTool, ultimateImpactTool } from "@/lib/flags"; import { calculateHeroPickrateMatrix } from "@/lib/hero-pickrate-utils"; @@ -137,7 +122,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; @@ -180,76 +167,160 @@ 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/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..3a0a552bf 100644 --- a/src/app/team/[teamId]/page.tsx +++ b/src/app/team/[teamId]/page.tsx @@ -10,8 +10,10 @@ import { TooltipContent, 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 { ScoutingService } from "@/data/scouting"; +import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; @@ -96,12 +98,16 @@ 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: [] }; - 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..47392450b 100644 --- a/src/app/team/[teamId]/targets/page.tsx +++ b/src/app/team/[teamId]/targets/page.tsx @@ -2,12 +2,13 @@ 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"; -import { getUser } from "@/data/user-dto"; +} from "@/data/player"; +import { TeamSharedDataService } from "@/data/team"; +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 +24,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 ( @@ -85,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: { @@ -94,7 +101,9 @@ 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) @@ -144,10 +153,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/team/page.tsx b/src/app/team/page.tsx index e1531df70..48c8bd8ce 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,9 @@ 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/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..6675720f0 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,28 @@ 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/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..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-overview-dto"; +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 1fe56cdfb..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-dto"; +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 2101a5179..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-dto"; +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 cc450e473..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-dto"; +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 666661dcd..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-dto"; +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 3f1aee99f..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-dto"; +} 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 f909eafec..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-dto"; +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 68ab0b3b7..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-dto"; +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 fbfabf607..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-dto"; +import type { ComparisonStats } from "@/data/comparison/types"; import { cn } from "@/lib/utils"; import { AlertTriangle, 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/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/data-labeling/match-labeling-view.tsx b/src/components/data-labeling/match-labeling-view.tsx index 89482c2db..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/data-labeling-dto"; +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/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/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/map/analysis/analysis-card.tsx b/src/components/map/analysis/analysis-card.tsx index 6e61bd02e..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, @@ -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/types"; import { ArrowRightLeft, Crosshair, diff --git a/src/components/map/analysis/analysis-sections.tsx b/src/components/map/analysis/analysis-sections.tsx index 12b8fad08..4bf2ff3fc 100644 --- a/src/components/map/analysis/analysis-sections.tsx +++ b/src/components/map/analysis/analysis-sections.tsx @@ -17,12 +17,12 @@ 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, UltEfficiency, -} from "@/data/scrim-overview-dto"; +} 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 29423ec6a..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-overview-dto"; +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 f478e1523..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-ability-timing-dto"; +} 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 320d9b08f..b61d438ab 100644 --- a/src/components/map/default-overview.tsx +++ b/src/components/map/default-overview.tsx @@ -1,11 +1,18 @@ 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"; -import { getMapAbilityTiming } from "@/data/scrim-ability-timing-dto"; + RotationDeathService, +} from "@/data/map"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimAbilityTimingService, ScrimService } 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, @@ -16,14 +23,7 @@ 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 { 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"; @@ -68,7 +68,9 @@ 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,9 +255,27 @@ export async function DefaultOverview({ const [abilityTimingAnalysis, rotationDeathAnalysis, killfeedCalibration] = await Promise.all([ - getMapAbilityTiming(id, team1Name, team2Name), - positionalEnabled ? getRotationDeathAnalysis(id) : null, - positionalEnabled ? getKillfeedCalibration(id) : null, + AppRuntime.runPromise( + ScrimAbilityTimingService.pipe( + Effect.flatMap((svc) => + svc.getMapAbilityTiming(id, team1Name, team2Name) + ) + ) + ), + 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..8d450f085 100644 --- a/src/components/map/fight-timeline.tsx +++ b/src/components/map/fight-timeline.tsx @@ -13,13 +13,15 @@ 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 { 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"; @@ -124,6 +126,8 @@ type FightTimelineProps = { t: ReturnType; tUlt: ReturnType; calibrationData?: SerializedCalibrationData; + canvasImportEnabled?: boolean; + mapDataId?: number; }; export function FightTimeline({ @@ -142,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)); @@ -219,7 +225,7 @@ export function FightTimeline({ {/* Left labels column — fight start/end markers */}
{t("fight", { num: fightIndex + 1 })} {t("start")} + {canvasImportEnabled && mapDataId != null && calibrationData && ( + + )}
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..ba696589d 100644 --- a/src/components/map/import-to-canvas-link.tsx +++ b/src/components/map/import-to-canvas-link.tsx @@ -1,10 +1,10 @@ "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"; -import { toHero, toKebabCase } from "@/lib/utils"; +import { cn, toHero, toKebabCase } from "@/lib/utils"; import { coachingCanvasStore, flushCanvasToLocalStorage, @@ -68,12 +68,14 @@ export function ImportToCanvasLink({ mapDataId, team1, t, + className, }: { fight: Fight; calibrationData: NonNullable; mapDataId: number; team1: string; t: ReturnType; + className?: string; }) { const router = useRouter(); const [loading, setLoading] = useState(false); @@ -132,7 +134,10 @@ export function ImportToCanvasLink({ return (
); 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..8d689c25d 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,21 @@ 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(mapDataId)) + ) + ) + ) : null; const roundEnds = removeDuplicateRows(roundEndRows); @@ -233,7 +243,7 @@ export async function Killfeed({ team2Color={team2Color} calibrationData={calibrationData} canvasImportEnabled={canvasEnabled && positionalEnabled} - mapDataId={id} + mapDataId={mapDataId} />
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/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..1ffd20c35 100644 --- a/src/components/map/replay/replay-tab.tsx +++ b/src/components/map/replay/replay-tab.tsx @@ -1,10 +1,14 @@ -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..881f303a7 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,9 @@ 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/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/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/hero-ban-chart.tsx b/src/components/scouting/hero-ban-chart.tsx index f33b5485e..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-dto"; +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 48d0ad259..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-dto"; +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/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/match-history-table.tsx b/src/components/scouting/match-history-table.tsx index 48fb69c98..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-dto"; +import type { ScoutingMatchHistoryEntry } from "@/data/scouting/types"; import { useTranslations } from "next-intl"; import { useState } from "react"; 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..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-scouting-analytics-dto"; +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/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/scouting/scouting-recommendations.tsx b/src/components/scouting/scouting-recommendations.tsx index 4d99274d3..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-dto"; +} 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 b55636b73..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-dto"; +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 a6f8ff721..498714fa7 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/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 b91d173f7..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-dto"; +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 7ff3d02f2..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-overview-dto"; +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 398be1a46..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-overview-dto"; +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 4177baa3f..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-overview-dto"; +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 77efb0662..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-ability-timing-dto"; +} 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 00463ef77..a352e90f1 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/types"; +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..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-overview-dto"; +} 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 dd68326cd..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-overview-dto"; +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 3e3042af6..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-overview-dto"; +} from "@/data/scrim/types"; import { cn } from "@/lib/utils"; export function HighlightedPct({ diff --git a/src/components/scrim/swap-win-rate-chart.tsx b/src/components/scrim/swap-win-rate-chart.tsx index 114eeef5a..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-hero-swap-dto"; +import type { SwapTimingOutcome, SwapWinrateBucket } from "@/data/team/types"; import { Bar, BarChart, diff --git a/src/components/scrim/ult-comparison-chart.tsx b/src/components/scrim/ult-comparison-chart.tsx index e7ea7793f..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-overview-dto"; +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 3c6558782..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-dto"; +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 2d993bb3b..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-dto"; +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 a2483e9fc..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-dto"; +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 b89e54967..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-dto"; +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 c52e86457..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-dto"; +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/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..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-ban-impact-dto"; +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 19961fd19..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-ban-impact-dto"; +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/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 584c0393b..3a0d5a4c8 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 { HeroPickrateMatrix } from "@/data/team/types"; +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..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-matchup-winrate-dto"; +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/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/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/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/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..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-hero-swap-dto"; +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/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"; diff --git a/src/components/team/team-settings-form.tsx b/src/components/team/team-settings-form.tsx index b01bc0ebb..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-dto"; +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/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/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/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..7c134f4b1 --- /dev/null +++ b/src/data/admin/index.ts @@ -0,0 +1,18 @@ +import "server-only"; + +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/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/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-dto.ts b/src/data/comparison/computation.ts similarity index 55% rename from src/data/comparison-dto.ts rename to src/data/comparison/computation.ts index ee76760af..98c7e08f9 100644 --- a/src/data/comparison-dto.ts +++ b/src/data/comparison/computation.ts @@ -1,153 +1,20 @@ -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[]; -}; +import type { AggregatedStats, TrendsAnalysis } from "./types"; -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 { +export function calculatePer10(value: number, timePlayed: number): number { if (timePlayed === 0) return 0; return (value / timePlayed) * 600; } -function calculatePercentage(value: number, total: number): number { +export function calculatePercentage(value: number, total: number): number { if (total === 0) return 0; return (value / total) * 100; } @@ -174,12 +41,6 @@ function aggregateCalculatedStats( 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: @@ -721,399 +582,3 @@ export function calculateTrends( 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/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..ea296d7a8 --- /dev/null +++ b/src/data/comparison/index.ts @@ -0,0 +1,32 @@ +import "server-only"; + +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[]; +}; 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/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..fa2bd3c6d --- /dev/null +++ b/src/data/hero/index.ts @@ -0,0 +1,8 @@ +import "server-only"; + +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..12f1e6d62 --- /dev/null +++ b/src/data/hero/service.ts @@ -0,0 +1,398 @@ +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/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..26b714428 --- /dev/null +++ b/src/data/intelligence/hero-ban-service.ts @@ -0,0 +1,553 @@ +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..de34dc3ea --- /dev/null +++ b/src/data/intelligence/index.ts @@ -0,0 +1,34 @@ +import "server-only"; + +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..0e46a15f6 --- /dev/null +++ b/src/data/intelligence/map-service.ts @@ -0,0 +1,542 @@ +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/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/layer.ts b/src/data/layer.ts new file mode 100644 index 000000000..7b222a22c --- /dev/null +++ b/src/data/layer.ts @@ -0,0 +1,98 @@ +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 { 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"; +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, + TeamAnalyticsServiceLive, + TeamPredictionServiceLive +); 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/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..5f987ecb2 --- /dev/null +++ b/src/data/map/heatmap/index.ts @@ -0,0 +1,13 @@ +import "server-only"; + +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..ea67295db --- /dev/null +++ b/src/data/map/index.ts @@ -0,0 +1,89 @@ +import "server-only"; + +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..b63789323 --- /dev/null +++ b/src/data/map/killfeed/index.ts @@ -0,0 +1,28 @@ +import "server-only"; + +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..554d133b6 --- /dev/null +++ b/src/data/map/killfeed/service.ts @@ -0,0 +1,278 @@ +import { EffectObservabilityLive } from "@/instrumentation"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import prisma from "@/lib/prisma"; +import { groupKillsIntoFights, type Fight } from "@/lib/utils"; +import { Cache, Context, Duration, Effect, Layer, Metric } from "effect"; +import { MapQueryError } from "../errors"; +import { + killfeedUltSpansQueryDuration, + killfeedUltSpansQueryErrorTotal, + killfeedUltSpansQuerySuccessTotal, + mapCacheMissTotal, + mapCacheRequestTotal, +} from "../metrics"; +import type { FightUltimateData, UltimateSpan } from "./types"; +export { + getEventTime, + hasAnyUltFeature, + isKillDuringUlt, + mergeKillfeedEvents, +} 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 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..7e5ddbeba --- /dev/null +++ b/src/data/map/killfeed/types.ts @@ -0,0 +1,141 @@ +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; + +// ── 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/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..79c972e35 --- /dev/null +++ b/src/data/map/replay/index.ts @@ -0,0 +1,15 @@ +import "server-only"; + +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..e23aeaa6b --- /dev/null +++ b/src/data/map/tempo-service.ts @@ -0,0 +1,335 @@ +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, + 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 }; + +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 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..8b839eee2 --- /dev/null +++ b/src/data/map/types.ts @@ -0,0 +1,107 @@ +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" }) +); + +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), +}); 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-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/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..3dfccfd42 --- /dev/null +++ b/src/data/player/index.ts @@ -0,0 +1,72 @@ +import "server-only"; + +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-dto.ts b/src/data/player/intelligence-service.ts similarity index 56% rename from src/data/player-intelligence-dto.ts rename to src/data/player/intelligence-service.ts index 9a41d88a9..9517ccfd6 100644 --- a/src/data/player-intelligence-dto.ts +++ b/src/data/player/intelligence-service.ts @@ -1,75 +1,44 @@ -import "server-only"; - -import { assessConfidence, type ConfidenceMetadata } from "@/lib/confidence"; +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, 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"; +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"; -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; @@ -100,7 +69,6 @@ function aggregatePlayerHeroes( ): PlayerHeroAgg[] { if (!teamRosterSet.has(playerName)) return []; - // Group by hero, deduplicating by MapDataId (take latest stat per map per hero) const heroData = new Map< string, { @@ -159,16 +127,6 @@ function aggregatePlayerHeroes( .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> { @@ -402,8 +360,6 @@ function computeVulnerabilities( ? (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; @@ -459,93 +415,246 @@ function findBestPlayer( }; } -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); - } - } +export type IntelligenceServiceInterface = { + readonly getPlayerIntelligence: ( + userTeamId: number, + opponentAbbr: string | null, + profile?: DataAvailabilityProfile + ) => Effect.Effect; +}; - const zScores = computeIntraTeamZScores(allPlayerHeroes); - const playerDepths = buildPlayerDepths(allPlayerHeroes, zScores); +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 }, + }) + ); - // 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 { 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 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 = 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 + ); + } } } } - } - const includesScrimData = !!profile && hasScrimData(profile); - if (includesScrimData) { - const scrimBans = await getOpponentScrimHeroBans( - userTeamId, - opponentAbbr + wideEvent.total_opponent_maps = totalOpponentMaps; + wideEvent.player_count = allPlayerHeroes.size; + + const vulnerabilities = computeVulnerabilities( + playerDepths, + opponentBanCounts, + totalOpponentMaps ); - 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 + ); - const bestPlayer = findBestPlayer( - playerDepths, - opponentBanCounts, - totalOpponentMaps - ); + wideEvent.outcome = "success"; + yield* Metric.increment(playerIntelligenceQuerySuccessTotal); - return { - playerDepths, - substitutionRates, - vulnerabilities, - bestPlayer, - }; -} + 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)) + ); + }, + }); -export const getPlayerIntelligence = cache(getPlayerIntelligenceFn); + 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-dto.ts b/src/data/player/scouting-analytics-service.ts similarity index 65% rename from src/data/player-scouting-analytics-dto.ts rename to src/data/player/scouting-analytics-service.ts index 8a3cd332d..e56a3b240 100644 --- a/src/data/player-scouting-analytics-dto.ts +++ b/src/data/player/scouting-analytics-service.ts @@ -1,16 +1,33 @@ -import "server-only"; - +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 } from "react"; +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"; -export type HeroStatZScore = { +type HeroStatZScore = { stat: ValidStatColumn; per10: number; zScore: number | null; @@ -19,105 +36,6 @@ export type HeroStatZScore = { 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; @@ -427,11 +345,7 @@ function computeHeroZScores( 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; - } + compositeZScore += z * weight; } return { @@ -754,186 +668,6 @@ async function fetchCompetitiveMapWinrates( }; } -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[]; @@ -949,14 +683,14 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { strengths.push({ category: "hero", label: `Elite ${hero.hero}`, - detail: `Composite z-score of +${hero.compositeZScore.toFixed(1)}σ across ${hero.mapsPlayed} maps`, + 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)}σ across ${hero.mapsPlayed} maps`, + detail: `Composite z-score of ${hero.compositeZScore.toFixed(1)}\u03C3 across ${hero.mapsPlayed} maps`, value: hero.compositeZScore, }); } @@ -973,7 +707,7 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { 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)`, + 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 ( @@ -985,7 +719,7 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { 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)`, + detail: `${stat.zScore.toFixed(1)}\u03C3 on ${hero.hero} (${stat.per10.toFixed(0)}/10 vs avg ${stat.heroAvgPer10.toFixed(0)}/10)`, value: stat.zScore, }); } @@ -1024,7 +758,7 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { weaknesses.push({ category: "stat", label: "Inconsistent performer", - detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating — high variance across maps`, + detail: `${advancedMetrics.consistencyScore.toFixed(0)}/100 consistency rating \u2014 high variance across maps`, value: -advancedMetrics.consistencyScore, }); } @@ -1035,7 +769,7 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { weaknesses.push({ category: "hero", label: "Narrow hero pool", - detail: `${delta.toFixed(1)}σ drop-off from ${heroes[0].hero} to ${heroes[1].hero}`, + detail: `${delta.toFixed(1)}\u03C3 drop-off from ${heroes[0].hero} to ${heroes[1].hero}`, value: -delta, }); } @@ -1077,3 +811,340 @@ function generatePlayerInsights(analytics: PlayerScoutingAnalytics): { 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..c1dbd8e95 --- /dev/null +++ b/src/data/player/targets-service.ts @@ -0,0 +1,500 @@ +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" }) +); 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/runtime.ts b/src/data/runtime.ts new file mode 100644 index 000000000..bfdd3c516 --- /dev/null +++ b/src/data/runtime.ts @@ -0,0 +1,6 @@ +import "server-only"; + +import { ManagedRuntime } from "effect"; +import { DataLayerLive } from "./layer"; + +export const AppRuntime = ManagedRuntime.make(DataLayerLive); 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/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..6deba1de3 --- /dev/null +++ b/src/data/scouting/index.ts @@ -0,0 +1,30 @@ +import "server-only"; + +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 }[]; +}; 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/ability-timing-service.ts b/src/data/scrim/ability-timing-service.ts new file mode 100644 index 000000000..abf9ae7ff --- /dev/null +++ b/src/data/scrim/ability-timing-service.ts @@ -0,0 +1,1015 @@ +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..8274943ef --- /dev/null +++ b/src/data/scrim/index.ts @@ -0,0 +1,63 @@ +import "server-only"; + +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"; + +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/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..378f13336 --- /dev/null +++ b/src/data/scrim/opponent-service.ts @@ -0,0 +1,855 @@ +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-dto.ts b/src/data/scrim/overview-service.ts similarity index 58% rename from src/data/scrim-overview-dto.ts rename to src/data/scrim/overview-service.ts index 1ed87f41a..e464c6b09 100644 --- a/src/data/scrim-overview-dto.ts +++ b/src/data/scrim/overview-service.ts @@ -1,14 +1,23 @@ -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"; + aggregatePlayerStats, + calculateTrends, +} from "@/data/comparison/computation"; +import type { TrendsAnalysis } from "@/data/comparison/types"; +import { + ScrimAbilityTimingService, + ScrimAbilityTimingServiceLive, +} from "@/data/scrim/ability-timing-service"; +import { filterUtilityRoundStartSwaps } from "@/data/team/hero-swap-service"; +import type { + SwapRecord, + SwapTimingOutcome, + SwapWinrateBucket, +} from "@/data/team/types"; +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 { @@ -19,16 +28,12 @@ import { 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, @@ -39,207 +44,63 @@ import { 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[]; +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 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 class ScrimOverviewService extends Context.Tag( + "@app/data/scrim/ScrimOverviewService" +)() {} const STAT_LABELS: Record = { eliminations: "Eliminations", @@ -302,7 +163,6 @@ function generateInsights(players: PlayerScrimPerformance[]): 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) => @@ -317,7 +177,6 @@ function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { } } - // Most improved: player with trendData showing the most improving metrics const mostImproved = players .filter((p) => p.trendData && p.trendData.improvingMetrics.length > 0) .sort( @@ -336,7 +195,6 @@ function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { }); } - // Most declined const mostDeclined = players .filter((p) => p.trendData && p.trendData.decliningMetrics.length > 0) .sort( @@ -354,7 +212,6 @@ function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { }); } - // Positive outlier: highest z-score across all players/stats const positiveOutliers = players .flatMap((p) => p.outliers @@ -372,7 +229,6 @@ function generateInsights(players: PlayerScrimPerformance[]): ScrimInsight[] { }); } - // Negative outlier: most concerning stat const negativeOutliers = players .flatMap((p) => p.outliers @@ -1071,7 +927,6 @@ function computeScrimUltAnalysis( 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; @@ -1084,8 +939,6 @@ function computeScrimUltAnalysis( } } - // 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); @@ -1384,136 +1237,6 @@ function computeScrimUltAnalysis( }; } -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 }[], @@ -1765,664 +1488,850 @@ function computeScrimSwapAnalysis( }; } -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 } } }, - }); +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) 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); - } + if (maps.length === 0) { + wideEvent.map_count = 0; + wideEvent.outcome = "success"; + wideEvent.early_return = "no_maps"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } - const finalRoundByMapId = new Map(); - for (const round of finalRounds) { - if (round.MapDataId && !finalRoundByMapId.has(round.MapDataId)) { - finalRoundByMapId.set(round.MapDataId, round); - } - } + const mapDataIds = maps.flatMap((m) => m.mapData.map((md) => md.id)); - 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() + 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 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 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 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 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 }, + }) ); - } - 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)); - } + if (finalRoundStats.length === 0) { + wideEvent.outcome = "success"; + wideEvent.early_return = "no_final_round_stats"; + yield* Metric.increment(scrimOverviewSuccessTotal); + return emptyOverviewData(); + } - 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 lookup maps + const matchStartByMapId = new Map(); + for (const ms of matchStarts) { + if (ms.MapDataId) matchStartByMapId.set(ms.MapDataId, ms); + } - // 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 finalRoundByMapId = new Map(); + for (const round of finalRounds) { + if (round.MapDataId && !finalRoundByMapId.has(round.MapDataId)) { + finalRoundByMapId.set(round.MapDataId, round); + } + } - 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)); - } + 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); + } - 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>(); + 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); + } - for (const fight of fights) { - const overallVictim = fight.kills[0]?.victim_name; - if (overallVictim) { - overallFirstDeaths.set( - overallVictim, - (overallFirstDeaths.get(overallVictim) ?? 0) + 1 - ); + 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 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 killsByMap = new Map(); + for (const kill of allKills) { + if (!kill.MapDataId) continue; + if (!killsByMap.has(kill.MapDataId)) { + killsByMap.set(kill.MapDataId, []); } - const playerCounts = teamFirstDeaths.get(kill.victim_team)!; - playerCounts.set( - kill.victim_name, - (playerCounts.get(kill.victim_name) ?? 0) + 1 - ); + 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)); } - } - firstDeathLookup.set(mapId, { - fightCount: fights.length, - overallFirstDeaths, - teamFirstDeaths, - }); - } + 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 ourTeamNameByMap = getMapTeamNames(finalRoundStats); + const mapIdToMapDataId = new Map(); + for (const map of maps) { + if (map.mapData[0]) { + mapIdToMapDataId.set(map.id, map.mapData[0].id); + } + } - const fightAnalysis = computeScrimFightAnalysis( - mapDataIds, - killsByMap, - allUltimates, - ourTeamNameByMap - ); + 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)); + } - const ultAnalysis = computeScrimUltAnalysis( - allUltimates, - fightsByMapWithUlts, - ourTeamNameByMap, - calculatedStats, - scrimPlayers - ); + 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 + ); + } - // 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 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 + ); + } + } - const ourTeamName = mdId ? (ourTeamNameByMap.get(mdId) ?? null) : null; - let winner: MapResult["winner"]; + firstDeathLookup.set(mapId, { + fightCount: fights.length, + overallFirstDeaths, + teamFirstDeaths, + }); + } - if (winnerName === "N/A" || !ourTeamName) { - winner = "draw"; - draws++; - } else if (winnerName === ourTeamName) { - winner = "our_team"; - wins++; - } else { - winner = "opponent"; - losses++; - } + const ourTeamNameByMap = getMapTeamNames(finalRoundStats); - mapResults.push({ mapId: map.id, mapName: map.name, winner }); - } + const fightAnalysis = computeScrimFightAnalysis( + mapDataIds, + killsByMap, + allUltimates, + ourTeamNameByMap + ); - 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 ultAnalysis = computeScrimUltAnalysis( + allUltimates, + fightsByMapWithUlts, + ourTeamNameByMap, + calculatedStats, + scrimPlayers + ); - const playerCalculatedMap = new Map(); - for (const row of calculatedStats) { - if (!playerCalculatedMap.has(row.playerName)) { - playerCalculatedMap.set(row.playerName, []); - } - playerCalculatedMap.get(row.playerName)!.push(row); - } + // 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 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, []); + 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 }); } - rowsByMap.get(row.MapDataId)!.push(row); - } - const calcByMap = new Map(); - for (const row of playerCalcRows) { - if (!calcByMap.has(row.MapDataId)) { - calcByMap.set(row.MapDataId, []); + 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); } - 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 playerCalculatedMap = new Map(); + for (const row of calculatedStats) { + if (!playerCalculatedMap.has(row.playerName)) { + playerCalculatedMap.set(row.playerName, []); + } + playerCalculatedMap.get(row.playerName)!.push(row); + } - const aggregated = aggregatePlayerStats( - playerRows, - playerCalcRows, - perMapStats, - perMapCalculatedStats - ); + 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 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 calcByMap = new Map(); + for (const row of playerCalcRows) { + if (!calcByMap.has(row.MapDataId)) { + calcByMap.set(row.MapDataId, []); + } + calcByMap.get(row.MapDataId)!.push(row); + } - 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 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 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 aggregated = aggregatePlayerStats( + playerRows, + playerCalcRows, + perMapStats, + perMapCalculatedStats + ); - const uniqueHeroes = new Set(); - for (const player of basePlayers) { - if (player.heroTimePlayed >= 300) { - uniqueHeroes.add(player.primaryHero); - } - } + 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 baselineMap = await getBatchedStatDistributionBaselines( - Array.from(uniqueHeroes) - ); + 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 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 - ); + 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); + } + } - for (const statDef of comparisonStats) { - const baseline = baselineMap.get( - createBaselineKey(player.primaryHero, statDef.stat) + 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 }, + }) ); - 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), - }); - } - } + 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, - }; - }); + 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) - ); + 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 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 = await getScrimAbilityTiming(scrimId, teamId); + 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 }, + }) + ); - return { - mapCount: maps.length, - wins, - losses, - draws, - ourTeamName: firstOurTeamName, - opponentTeamName, - mapResults, - teamPlayers, - insights, - teamTotals, - fightAnalysis, - ultAnalysis, - swapAnalysis, - abilityTimingAnalysis, - }; -} + 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 }); + } -export const getScrimOverview = cache(getScrimOverviewFn); + 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..13aade097 --- /dev/null +++ b/src/data/scrim/scrim-service.ts @@ -0,0 +1,1114 @@ +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 } from "./types"; +import type { Winrate } from "./types"; + +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..921f347e8 --- /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/types"; +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 Winrate = { map: string; wins: number; date: Date }[]; + +export type { PlayerUltSummary } from "./ult-helpers"; +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; +} 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-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-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-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-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/team-ability-impact-dto.ts b/src/data/team/ability-impact-service.ts similarity index 53% rename from src/data/team-ability-impact-dto.ts rename to src/data/team/ability-impact-service.ts index c1dc4c5c6..1e0803291 100644 --- a/src/data/team-ability-impact-dto.ts +++ b/src/data/team/ability-impact-service.ts @@ -1,51 +1,62 @@ -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[]; -}; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import { TeamQueryError } from "./errors"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} 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, + 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 }; } - function finalizeScenario(s: AbilityScenarioStats): AbilityScenarioStats { - return { - ...s, - winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0, - }; + return { ...s, winrate: s.fights > 0 ? (s.wins / s.fights) * 100 : 0 }; } type FightEvent = { @@ -56,13 +67,7 @@ type FightEvent = { victim_team?: string; victim_hero?: string; }; - -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; - +type Fight = { events: FightEvent[]; start: number; end: number }; type AbilityEvent = { match_time: number; player_team: string; @@ -70,9 +75,7 @@ type AbilityEvent = { 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( @@ -82,12 +85,8 @@ function processAbilityImpactAnalysis( ): AbilityImpactAnalysis { const { teamRosterSet, mapDataIds, allPlayerStats, allKills, allRezzes } = sharedData; + if (mapDataIds.length === 0) return { byHero: {}, availableHeroes: [] }; - 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( @@ -95,12 +94,9 @@ function processAbilityImpactAnalysis( allPlayerStats, teamRosterSet ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } + if (teamName) teamNameByMapId.set(mapDataId, teamName); } - // Group events by MapDataId const killsByMap = new Map(); const rezzesByMap = new Map(); const ab1ByMap = new Map(); @@ -112,21 +108,18 @@ function processAbilityImpactAnalysis( 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, []); @@ -134,7 +127,6 @@ function processAbilityImpactAnalysis( } } - // Accumulate per-hero stats type AbilitySlotAccum = { usedByUs: AbilityScenarioStats; notUsedByUs: AbilityScenarioStats; @@ -142,16 +134,11 @@ function processAbilityImpactAnalysis( notUsedByEnemy: AbilityScenarioStats; totalFightsAnalyzed: number; }; - - type HeroAccum = { - ability1: AbilitySlotAccum; - ability2: AbilitySlotAccum; - }; - + type HeroAccum = { ability1: AbilitySlotAccum; ability2: AbilitySlotAccum }; const heroStats = new Map(); function getOrCreateHero(hero: string): HeroAccum { - if (!heroStats.has(hero)) { + if (!heroStats.has(hero)) heroStats.set(hero, { ability1: { usedByUs: emptyScenario(), @@ -168,7 +155,6 @@ function processAbilityImpactAnalysis( totalFightsAnalyzed: 0, }, }); - } return heroStats.get(hero)!; } @@ -186,10 +172,8 @@ function processAbilityImpactAnalysis( 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, @@ -208,13 +192,10 @@ function processAbilityImpactAnalysis( 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 = { @@ -229,133 +210,97 @@ function processAbilityImpactAnalysis( } } - // Analyze each fight for (const fight of fights) { - // Determine winner by kill differential - let ourKills = 0; - let enemyKills = 0; - + let ourKills = 0, + enemyKills = 0; for (const event of fight.events) { if (event.event_type === "mercy_rez") { - if (event.victim_team === ourTeamName) { + if (event.victim_team === ourTeamName) enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 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)) { + 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) { + 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]) { + 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(); + 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); } - // Classify per hero for (const [hero, presence] of heroPresence) { const stats = getOrCreateHero(hero); - if (presence.ourTeam) { stats.ability1.totalFightsAnalyzed++; - if (ourAb1Heroes.has(hero)) { + if (ourAb1Heroes.has(hero)) incrementScenario(stats.ability1.usedByUs, won); - } else { - incrementScenario(stats.ability1.notUsedByUs, won); - } - + else incrementScenario(stats.ability1.notUsedByUs, won); stats.ability2.totalFightsAnalyzed++; - if (ourAb2Heroes.has(hero)) { + if (ourAb2Heroes.has(hero)) incrementScenario(stats.ability2.usedByUs, won); - } else { - incrementScenario(stats.ability2.notUsedByUs, won); - } + else incrementScenario(stats.ability2.notUsedByUs, won); } - if (presence.enemyTeam) { - if (enemyAb1Heroes.has(hero)) { + if (enemyAb1Heroes.has(hero)) incrementScenario(stats.ability1.usedByEnemy, won); - } else { - incrementScenario(stats.ability1.notUsedByEnemy, won); - } - - if (enemyAb2Heroes.has(hero)) { + else incrementScenario(stats.ability1.notUsedByEnemy, won); + if (enemyAb2Heroes.has(hero)) incrementScenario(stats.ability2.usedByEnemy, won); - } else { - incrementScenario(stats.ability2.notUsedByEnemy, 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: { @@ -382,60 +327,157 @@ function processAbilityImpactAnalysis( } 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; - }) + .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 }; } -async function getTeamAbilityImpactUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); +export type TeamAbilityImpactServiceInterface = { + readonly getTeamAbilityImpact: ( + teamId: number, + dateRange?: TeamDateRange + ) => Effect.Effect; +}; - if (sharedData.mapDataIds.length === 0) { - return { byHero: {}, availableHeroes: [] }; +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 [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 - ); -} + 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 dr = parseDateRangeFromCacheKey(rest); + return getTeamAbilityImpact(Number(teamIdStr), dr).pipe( + Effect.tap(() => Metric.increment(teamCacheMissTotal)) + ); + }, + }); -export const getTeamAbilityImpact = cache(getTeamAbilityImpactUncached); + 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/analytics-service.ts b/src/data/team/analytics-service.ts new file mode 100644 index 000000000..262b4780d --- /dev/null +++ b/src/data/team/analytics-service.ts @@ -0,0 +1,756 @@ +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, + parseDateRangeFromCacheKey, +} from "./shared-core"; +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", + { + 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, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerHeroData, + 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); + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; + } + + 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); + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; + } + + 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); + const _empty: HeroPickrateMatrix = { players: [], allHeroes: [] }; + return _empty; + } + + 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: [], + } satisfies 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: [], + } satisfies 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: [], + } satisfies 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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/ban-impact-service.ts b/src/data/team/ban-impact-service.ts new file mode 100644 index 000000000..7986735b9 --- /dev/null +++ b/src/data/team/ban-impact-service.ts @@ -0,0 +1,397 @@ +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, + parseDateRangeFromCacheKey, +} 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, + 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; +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 dr = parseDateRangeFromCacheKey(rest); + 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/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-fight-stats-dto.tsx b/src/data/team/fight-stats-service.ts similarity index 55% rename from src/data/team-fight-stats-dto.tsx rename to src/data/team/fight-stats-service.ts index 6afb8de2f..4145c4b30 100644 --- a/src/data/team-fight-stats-dto.tsx +++ b/src/data/team/fight-stats-service.ts @@ -1,59 +1,49 @@ -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; -}; +import { + Cache, + Context, + Duration, + Effect, + Layer, + Metric, + MetricBoundaries, +} from "effect"; +import type { TeamQueryError } from "./errors"; +import type { ExtendedTeamData, TeamDateRange } from "./shared-core"; +import { + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} 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, + } +); -type FightEvent = Kill & { - ultimate_id?: number; -}; +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 } from "./types"; +import type { TeamFightStats } from "./types"; + +type FightEvent = Kill & { ultimate_id?: number }; type Fight = { events: FightEvent[]; @@ -69,16 +59,14 @@ type FightAnalysis = { isDryFight: boolean; isReversal: boolean; ultCount: number; - wastedUlts: number; // ults used after fight was likely lost + wastedUlts: number; }; 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" ); @@ -86,12 +74,10 @@ function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { (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; @@ -105,32 +91,24 @@ function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { ourKills = Math.max(0, ourKills - 1); } } else if (event.event_type === "kill") { - if (event.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } + if (event.attacker_team === ourTeamName) ourKills++; + else enemyKills++; } - if (enemyKills - ourKills >= 2) { - wasDown2Plus = true; - } + if (enemyKills - ourKills >= 2) wasDown2Plus = true; if ( event.event_type === "ultimate_start" && event.attacker_team === ourTeamName ) { const killDiff = ourKills - enemyKills; - if (killDiff <= -3) { - wastedUlts++; - } + 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 @@ -139,7 +117,6 @@ function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { ? firstKill.victim_team === ourTeamName : false; - // Find first ultimate usage const firstUlt = ultimates[0]; const usedFirstUlt = firstUlt ? firstUlt.attacker_team === ourTeamName @@ -157,14 +134,6 @@ function analyzeFightOutcome(fight: Fight, ourTeamName: string): FightAnalysis { }; } -async function getTeamFightStatsUncached( - teamId: number, - dateRange?: TeamDateRange -): Promise { - const sharedData = await getExtendedTeamData(teamId, { dateRange }); - return processTeamFightStats(sharedData); -} - function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { const { teamRosterSet, @@ -176,39 +145,9 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { } = 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, - }; + return emptyFightStats(); } - // Build team name lookup for each map based on roster const teamNameByMapId = new Map(); for (const mapDataId of mapDataIds) { const teamName = findTeamNameForMapInMemory( @@ -216,42 +155,34 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { allPlayerStats, teamRosterSet ); - if (teamName) { - teamNameByMapId.set(mapDataId, teamName); - } + 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, []); - } + 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, []); - } + 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, []); - } + 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; @@ -272,21 +203,17 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { 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) { + if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) continue; - } - // Build event array const events: FightEvent[] = [ ...kills, ...rezzes.map((rez) => mercyRezToKillEvent(rez)), @@ -296,10 +223,8 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { })), ]; - // 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; @@ -317,33 +242,25 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { } } - // Analyze each fight for (const fight of fights) { const analysis = analyzeFightOutcome(fight, ourTeamName); totalFights++; - - if (analysis.won) { - fightsWon++; - } else { - fightsLost++; - } + 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++; @@ -354,19 +271,14 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { if (analysis.isReversal) nonDryFightReversals++; } - // Track ultimate economy metrics totalUltsUsed += analysis.ultCount; totalWastedUlts += analysis.wastedUlts; - if (analysis.won) { - ultsInWonFights += analysis.ultCount; - } else { - ultsInLostFights += analysis.ultCount; - } + 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; @@ -381,8 +293,6 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { 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 = @@ -420,4 +330,132 @@ function processTeamFightStats(sharedData: ExtendedTeamData): TeamFightStats { }; } -export const getTeamFightStats = cache(getTeamFightStatsUncached); +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 dr = parseDateRangeFromCacheKey(rest); + 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..bbc834767 --- /dev/null +++ b/src/data/team/hero-pool-service.ts @@ -0,0 +1,706 @@ +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 type { BaseTeamData, TeamDateRange } from "./shared-core"; +import { + buildCapturesMaps, + buildFinalRoundMap, + buildMatchStartMap, + buildProgressMaps, + findTeamNameForMapInMemory, + parseDateRangeFromCacheKey, +} 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, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, +} from "./types"; +import type { + HeroPlaytime, + HeroWinrate, + HeroSpecialist, + HeroDiversity, + HeroPoolAnalysis, + HeroPoolRawData, +} from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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..b7b9416df --- /dev/null +++ b/src/data/team/hero-swap-service.ts @@ -0,0 +1,664 @@ +import prisma from "@/lib/prisma"; +import { calculateWinner } from "@/lib/winrate"; +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, + parseDateRangeFromCacheKey, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +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", + { 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" +); + +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 dr = parseDateRangeFromCacheKey(rest); + 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..5f4e5e27d --- /dev/null +++ b/src/data/team/index.ts @@ -0,0 +1,175 @@ +import "server-only"; + +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 type { + // hero-swap types + SwapTimingBucket, + SwapWinrateBucket, + SwapPair, + PlayerSwapStats, + SwapTimingOutcome, + TeamHeroSwapStats, + SwapRecord, + // stats types + TeamWinrates, + TopMapByPlaytime, + BestMapByWinrate, + // trends types + WinrateDataPoint, + RecentFormMatch, + RecentForm, + StreakInfo, + // 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, + // analytics types + HeroPickrate, + PlayerHeroData, + HeroPickrateMatrix, + HeroPickrateRawData, + PlayerMapPerformance, + PlayerMapPerformanceMatrix, + // prediction types + SimulatorContext, +} 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 } from "./fight-stats-service"; + +export { + TeamRoleStatsService, + TeamRoleStatsServiceLive, +} from "./role-stats-service"; +export type { TeamRoleStatsServiceInterface } from "./role-stats-service"; + +export { + TeamHeroPoolService, + TeamHeroPoolServiceLive, +} from "./hero-pool-service"; +export type { TeamHeroPoolServiceInterface } from "./hero-pool-service"; + +export { + TeamHeroSwapService, + TeamHeroSwapServiceLive, +} from "./hero-swap-service"; +export type { TeamHeroSwapServiceInterface } from "./hero-swap-service"; + +export { TeamMapModeService, TeamMapModeServiceLive } from "./map-mode-service"; +export type { TeamMapModeServiceInterface } from "./map-mode-service"; + +export { + TeamQuickWinsService, + TeamQuickWinsServiceLive, +} from "./quick-wins-service"; +export type { TeamQuickWinsServiceInterface } from "./quick-wins-service"; + +export { TeamUltService, TeamUltServiceLive } from "./ult-service"; +export type { TeamUltServiceInterface } from "./ult-service"; + +export { + TeamBanImpactService, + TeamBanImpactServiceLive, +} from "./ban-impact-service"; +export type { TeamBanImpactServiceInterface } from "./ban-impact-service"; + +export { + TeamAbilityImpactService, + TeamAbilityImpactServiceLive, +} from "./ability-impact-service"; +export type { TeamAbilityImpactServiceInterface } 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 } 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/map-mode-service.ts b/src/data/team/map-mode-service.ts new file mode 100644 index 000000000..4d1b9f33b --- /dev/null +++ b/src/data/team/map-mode-service.ts @@ -0,0 +1,388 @@ +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, + parseDateRangeFromCacheKey, +} 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, MapModePerformance } from "./types"; +import type { MapModeStats, MapModePerformance } from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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..16ea9a1b8 --- /dev/null +++ b/src/data/team/matchup-service.ts @@ -0,0 +1,464 @@ +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, + parseDateRangeFromCacheKey, +} 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, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./types"; +import type { + MapHeroEntry, + MatchupMapResult, + MatchupWinrateData, + EnemyHeroWinrate, + EnemyHeroAnalysis, +} from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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/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/prediction-service.ts b/src/data/team/prediction-service.ts new file mode 100644 index 000000000..e3c0b96c6 --- /dev/null +++ b/src/data/team/prediction-service.ts @@ -0,0 +1,283 @@ +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, parseDateRangeFromCacheKey } 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 dr = parseDateRangeFromCacheKey(rest); + 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 + ) + ) +); diff --git a/src/data/team-quick-wins-dto.tsx b/src/data/team/quick-wins-service.ts similarity index 59% rename from src/data/team-quick-wins-dto.tsx rename to src/data/team/quick-wins-service.ts index 5f4546348..e080bf462 100644 --- a/src/data/team-quick-wins-dto.tsx +++ b/src/data/team/quick-wins-service.ts @@ -1,50 +1,57 @@ -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 { + 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 "./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; -}; + parseDateRangeFromCacheKey, +} from "./shared-core"; +import { teamCacheRequestTotal, teamCacheMissTotal } from "./metrics"; +import { + TeamSharedDataService, + TeamSharedDataServiceLive, +} from "./shared-data-service"; -type FightEvent = Kill & { - ultimate_id?: number; -}; +const quickWinsQuerySuccessTotal = Metric.counter( + "team.quick_wins.query.success", + { description: "Total successful team quick wins queries", incremental: true } +); -type Fight = { - events: FightEvent[]; - start: number; - end: number; -}; +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 } from "./types"; +import type { QuickWinsStats } from "./types"; + +type FightEvent = Kill & { ultimate_id?: number }; +type Fight = { events: FightEvent[]; start: number; end: number }; function analyzeFightOutcome( fight: Fight, @@ -53,7 +60,6 @@ function analyzeFightOutcome( 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" ); @@ -63,22 +69,16 @@ function analyzeFightOutcome( for (const kill of kills) { if (kill.event_type === "mercy_rez") { - if (kill.victim_team === ourTeamName) { + if (kill.victim_team === ourTeamName) enemyKills = Math.max(0, enemyKills - 1); - } else { - ourKills = Math.max(0, ourKills - 1); - } + else ourKills = Math.max(0, ourKills - 1); } else { - if (kill.attacker_team === ourTeamName) { - ourKills++; - } else { - enemyKills++; - } + 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 @@ -87,26 +87,9 @@ function analyzeFightOutcome( 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> + fightStats: TeamFightStats ): QuickWinsStats { const { teamRosterSet, @@ -122,15 +105,10 @@ function processQuickWinsStats( 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; - }; + Scrim?: { id: number; name: string; date: Date }; }[]; if (mapDataRecords.length === 0) { @@ -157,20 +135,16 @@ function processQuickWinsStats( 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( @@ -183,7 +157,6 @@ function processQuickWinsStats( 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; @@ -199,29 +172,24 @@ function processQuickWinsStats( team2PointProgress: team2PointProgressMap.get(mapDataId) ?? [], }); - const isWin = winner === teamName; - matchResults.push({ mapDataId, scrimDate, teamName, - isWin, + isWin: winner === teamName, }); } - // 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", @@ -234,28 +202,22 @@ function processQuickWinsStats( for (const result of matchResults) { const dayOfWeek = result.scrimDate.getDay(); - if (!dayStats.has(dayOfWeek)) { + 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++; - } + 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; @@ -270,34 +232,25 @@ function processQuickWinsStats( } } - // 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, []); - } + 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, []); - } + 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, []); - } + if (!ultsByMap.has(ult.MapDataId)) ultsByMap.set(ult.MapDataId, []); ultsByMap.get(ult.MapDataId)!.push(ult); } } @@ -307,7 +260,6 @@ function processQuickWinsStats( 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( @@ -322,14 +274,12 @@ function processQuickWinsStats( ); 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) { + if (kills.length === 0 && rezzes.length === 0 && ults.length === 0) continue; - } const events: FightEvent[] = [ ...kills, @@ -339,13 +289,10 @@ function processQuickWinsStats( 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 = { @@ -360,28 +307,22 @@ function processQuickWinsStats( } } - // 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++; - } + if (analysis.won) successfulFirstPicks++; } } } const averageFightDuration = fightCount > 0 ? totalFightDuration / fightCount : null; - - // 4. First pick success rate - use the aggregated data const firstPickSuccessRate = totalFirstPicks > 0 ? { @@ -409,4 +350,109 @@ function processQuickWinsStats( }; } -export const getQuickWinsStats = cache(getQuickWinsStatsUncached); +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 dr = parseDateRangeFromCacheKey(rest); + 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..e067d4f28 --- /dev/null +++ b/src/data/team/role-stats-service.ts @@ -0,0 +1,870 @@ +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, + parseDateRangeFromCacheKey, +} 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, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, +} from "./types"; +import type { + RoleStats, + RolePerformanceStats, + RoleBalanceAnalysis, + RoleTrio, + RoleWinrateByMap, +} from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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-shared-core.ts b/src/data/team/shared-core.ts similarity index 91% rename from src/data/team-shared-core.ts rename to 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/shared-data-service.ts b/src/data/team/shared-data-service.ts new file mode 100644 index 000000000..c4fbf43aa --- /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/stats-service.ts b/src/data/team/stats-service.ts new file mode 100644 index 000000000..99ee72400 --- /dev/null +++ b/src/data/team/stats-service.ts @@ -0,0 +1,835 @@ +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, + parseDateRangeFromCacheKey, +} 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, TopMapByPlaytime, BestMapByWinrate } from "./types"; +import type { TeamWinrates, TopMapByPlaytime, BestMapByWinrate } from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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..ac42b9f1c --- /dev/null +++ b/src/data/team/trends-service.ts @@ -0,0 +1,717 @@ +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, + parseDateRangeFromCacheKey, +} 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, + RecentFormMatch, + RecentForm, + StreakInfo, +} from "./types"; +import type { + WinrateDataPoint, + RecentFormMatch, + RecentForm, + StreakInfo, +} from "./types"; + +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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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/types.ts b/src/data/team/types.ts new file mode 100644 index 000000000..1fb2f3d02 --- /dev/null +++ b/src/data/team/types.ts @@ -0,0 +1,615 @@ +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(), + 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 +>; + +// ---------- 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[]; +}; + +// ---------- 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[]; +}; diff --git a/src/data/team/ult-service.ts b/src/data/team/ult-service.ts new file mode 100644 index 000000000..a9cbaad79 --- /dev/null +++ b/src/data/team/ult-service.ts @@ -0,0 +1,798 @@ +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, + parseDateRangeFromCacheKey, +} 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, + 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 }; +} + +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 dr = parseDateRangeFromCacheKey(rest); + 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 dr = parseDateRangeFromCacheKey(rest); + 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) +); 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/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..3460facbb --- /dev/null +++ b/src/data/tournament/index.ts @@ -0,0 +1,16 @@ +import "server-only"; + +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..892e9a714 --- /dev/null +++ b/src/data/tournament/tournament-service.ts @@ -0,0 +1,582 @@ +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"; + +import type { Prisma } from "@prisma/client"; + +type GetTournamentResult = 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: ( + 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-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); 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..0f02c09e6 --- /dev/null +++ b/src/data/user/index.ts @@ -0,0 +1,8 @@ +import "server-only"; + +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" }) +); 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..204958d2c 100644 --- a/src/lib/ai/tools.ts +++ b/src/lib/ai/tools.ts @@ -1,22 +1,18 @@ -import { getComparisonStats } from "@/data/comparison-dto"; -import { getMapIntelligence } from "@/data/map-intelligence-dto"; -import { getPlayerIntelligence } from "@/data/player-intelligence-dto"; +import { ComparisonAggregationService } from "@/data/comparison"; +import { MapIntelligenceService } from "@/data/intelligence"; +import { IntelligenceService } from "@/data/player"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimAbilityTimingService, ScrimOverviewService } from "@/data/scrim"; import { - getScrimAbilityTiming, - getScrimFightTimelines, -} from "@/data/scrim-ability-timing-dto"; -import { getScrimOverview } from "@/data/scrim-overview-dto"; -import { getTeamAbilityImpact } from "@/data/team-ability-impact-dto"; -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"; @@ -46,7 +42,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({ @@ -266,7 +266,11 @@ 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 +301,11 @@ 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) }; }) ); @@ -381,10 +389,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, @@ -405,7 +419,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); }, }), @@ -420,11 +438,18 @@ 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, @@ -463,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; }, }), @@ -476,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; }, }), @@ -489,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; }, }), @@ -508,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; }, }), @@ -529,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; }, }), @@ -552,7 +601,11 @@ 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 { @@ -611,7 +664,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." }; @@ -722,7 +779,11 @@ 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..cd1e93e21 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -1,9 +1,11 @@ -import { getPlayerFinalStats } from "@/data/scrim-dto"; +import { AppRuntime } from "@/data/runtime"; +import { ScrimService } from "@/data/scrim"; import { resolveMapDataId } from "@/lib/map-data-resolver"; 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); @@ -262,7 +264,13 @@ 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 +320,13 @@ 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/audit-logs/index.ts b/src/lib/audit-logs/index.ts index 3a030b843..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("AuditLogService")< - AuditLogService, - Service ->() {} +export class AuditLogService extends Context.Tag( + "@app/audit-logs/AuditLogService" +)() {} // Service implementation function createService(): Effect.Effect { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8c7e3a92a..f37951eed 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,11 @@ 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 +329,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 +372,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..2432a1deb 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,25 @@ 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/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/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 b06b60ef0..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/opponent-strength-dto"; -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/mvp-score.ts b/src/lib/mvp-score.ts index ac59082fe..58d5b3ddb 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,13 @@ 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 +262,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`); diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 12472b125..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("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 }, }) diff --git a/src/lib/parser-client.ts b/src/lib/parser-client.ts new file mode 100644 index 000000000..e219bb848 --- /dev/null +++ b/src/lib/parser-client.ts @@ -0,0 +1,279 @@ +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*,\s*([^,\s]+)\s*,\s*([^)\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 fff385d3f..fce6a6548 100644 --- a/src/lib/parser.ts +++ b/src/lib/parser.ts @@ -1,3 +1,5 @@ +import "server-only"; + import type { CreateScrimRequestData } from "@/app/api/scrim/create-scrim/route"; import { calculateStats } from "@/lib/calculate-stats"; import { headers } from "@/lib/headers"; @@ -175,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]); 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/prediction-engine.ts b/src/lib/prediction-engine.ts index a161d39b9..2d820557c 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 { SimulatorContext } from "@/data/team/types"; +import type { RoleTrio } from "@/data/team/types"; import { mapNameToMapTypeMapping } from "@/types/map"; export type PredictionScenario = { diff --git a/src/lib/r2/index.ts b/src/lib/r2/index.ts index 72ffb81ee..3200b9770 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, @@ -102,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, @@ -226,12 +226,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({ 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/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"); 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); 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 diff --git a/test/__mocks__/next-axiom.ts b/test/__mocks__/next-axiom.ts new file mode 100644 index 000000000..09d7d962b --- /dev/null +++ b/test/__mocks__/next-axiom.ts @@ -0,0 +1,9 @@ +// Mock for next-axiom in test environment +function 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"], }, });