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