diff --git a/.gitignore b/.gitignore index 3b62085a..11920de3 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ playground/* # Search index search-index.json + +# TypeScript incremental build info +*.tsbuildinfo diff --git a/apps/frontend/app/all/page.tsx b/apps/frontend/app/all/page.tsx index a61cdc95..90f77731 100644 --- a/apps/frontend/app/all/page.tsx +++ b/apps/frontend/app/all/page.tsx @@ -1,8 +1,17 @@ import { PlayerList } from '../../components/PlayerList'; -import { getGlobalCounts } from '@teerank/teerank'; +import { + getGlobalCounts, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, +} from '@teerank/teerank'; +import { + countPlayersWithoutShared, + listPlayersWithoutShared, +} from '@prisma/client/sql'; import prisma from '../../utils/prisma'; import { searchParamSchema } from './schema'; import redis from '../../utils/redis'; +import { sharedHiddenParam } from '../../utils/shared'; export const metadata = { title: 'All Players - Teerank', @@ -19,12 +28,37 @@ export default async function Index({ }) { const { page } = searchParamSchema.parse(searchParams); + let nameFilter: string[] | undefined; + let filteredCount: number | undefined; + + if (sharedHiddenParam(searchParams)) { + const [names, counts] = await Promise.all([ + prisma.$queryRawTyped( + listPlayersWithoutShared( + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, + 100, + (page - 1) * 100 + ) + ), + prisma.$queryRawTyped( + countPlayersWithoutShared(STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO) + ), + ]); + + nameFilter = names.map((row) => row.name); + filteredCount = Number(counts[0]?.count ?? 0); + } + const players = await prisma.player.findMany({ + where: nameFilter === undefined ? undefined : { name: { in: nameFilter } }, select: { name: true, playTime: true, clanName: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { @@ -45,7 +79,7 @@ export default async function Index({ playTime: 'desc', }, take: 100, - skip: (page - 1) * 100, + skip: nameFilter === undefined ? (page - 1) * 100 : 0, }); const globalCounts = await getGlobalCounts(redis); @@ -56,7 +90,7 @@ export default async function Index({ {`Teerank is a simple and fast ranking system for Teeworlds.`}

({ @@ -66,6 +100,8 @@ export default async function Index({ rating: undefined, playTime: player.playTime, lastSeenAt: player.lastSeenAt, + pollCount: player.pollCount, + occurrenceCount: player.occurrenceCount, gameServers: player.gameServerStateClients.map((client) => ({ ip: client.gameServerState.gameServer?.ip ?? '', port: client.gameServerState.gameServer?.port ?? 0, diff --git a/apps/frontend/app/clan/[clanName]/PastMembersToggle.tsx b/apps/frontend/app/clan/[clanName]/PastMembersToggle.tsx deleted file mode 100644 index 24a73375..00000000 --- a/apps/frontend/app/clan/[clanName]/PastMembersToggle.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { useRouter, useSearchParams } from "next/navigation"; - -export function PastMembersToggle({ clanName, showPastMembers }: { clanName: string, showPastMembers: boolean }) { - const router = useRouter(); - const searchParams = useSearchParams(); - - return ( -
- -
- ); -} diff --git a/apps/frontend/app/clan/[clanName]/page.tsx b/apps/frontend/app/clan/[clanName]/page.tsx index e31be7ed..a2ff795b 100644 --- a/apps/frontend/app/clan/[clanName]/page.tsx +++ b/apps/frontend/app/clan/[clanName]/page.tsx @@ -6,8 +6,12 @@ import { PlayerList } from '../../../components/PlayerList'; import { searchParamPageSchema } from '../../../utils/page'; import { Metadata } from 'next'; import { encodeString } from '../../../utils/encoding'; -import Link from 'next/link'; -import { PastMembersToggle } from './PastMembersToggle'; +import { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; +import { + countClanPlayersWithoutShared, + listClanPlayersWithoutShared, +} from '@prisma/client/sql'; +import { sharedHiddenParam } from '../../../utils/shared'; export async function generateMetadata({ params, }: { @@ -35,6 +39,35 @@ export default async function Index({ const { page } = searchParamPageSchema.parse(searchParams); const showPastMembers = searchParams.past === 'true' || searchParams.past === '1'; + let nameFilter: string[] | undefined; + let filteredCount: number | undefined; + + if (sharedHiddenParam(searchParams)) { + const [names, counts] = await Promise.all([ + prisma.$queryRawTyped( + listClanPlayersWithoutShared( + clanName, + showPastMembers, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, + 100, + (page - 1) * 100 + ) + ), + prisma.$queryRawTyped( + countClanPlayersWithoutShared( + clanName, + showPastMembers, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO + ) + ), + ]); + + nameFilter = names.map((row) => row.playerName); + filteredCount = Number(counts[0]?.count ?? 0); + } + const clan = await prisma.clan.findUnique({ select: { activePlayerCount: true, @@ -51,6 +84,8 @@ export default async function Index({ name: true, clanName: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { gameServerState: { @@ -69,16 +104,17 @@ export default async function Index({ }, }, }, - where: showPastMembers ? undefined : { - player: { - clanName, - }, - }, + where: + nameFilter === undefined + ? showPastMembers + ? undefined + : { player: { clanName } } + : { playerName: { in: nameFilter } }, orderBy: { playTime: 'desc', }, take: 100, - skip: (page - 1) * 100, + skip: nameFilter === undefined ? (page - 1) * 100 : 0, }, }, where: { @@ -90,7 +126,9 @@ export default async function Index({ return notFound(); } - const playerCount = showPastMembers ? clan._count.clanPlayerInfos : clan.activePlayerCount; + const playerCount = + filteredCount ?? + (showPastMembers ? clan._count.clanPlayerInfos : clan.activePlayerCount); const maxPage = Math.ceil(playerCount / 100) || 1; if (page > maxPage) { @@ -111,11 +149,11 @@ export default async function Index({ return (
- ({ rank: index + 1, name: playerInfo.player.name, @@ -123,6 +161,8 @@ export default async function Index({ isActiveClan: playerInfo.player.clanName === clanName, playTime: playerInfo.playTime, lastSeenAt: playerInfo.player.lastSeenAt, + pollCount: playerInfo.player.pollCount, + occurrenceCount: playerInfo.player.occurrenceCount, gameServers: playerInfo.player.gameServerStateClients.map((client) => ({ ip: client.gameServerState.gameServer?.ip ?? '', port: client.gameServerState.gameServer?.port ?? 0, diff --git a/apps/frontend/app/gametype/[gameTypeName]/(lists)/page.tsx b/apps/frontend/app/gametype/[gameTypeName]/(lists)/page.tsx index 8e3cc424..c022fd75 100644 --- a/apps/frontend/app/gametype/[gameTypeName]/(lists)/page.tsx +++ b/apps/frontend/app/gametype/[gameTypeName]/(lists)/page.tsx @@ -6,6 +6,12 @@ import { notFound } from 'next/navigation'; import { encodeString } from '../../../../utils/encoding'; import { Metadata } from 'next'; import { z } from 'zod'; +import { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; +import { + countGameTypePlayersWithoutShared, + listGameTypePlayersWithoutShared, +} from '@prisma/client/sql'; +import { sharedHiddenParam } from '../../../../utils/shared'; export async function generateMetadata({ params, @@ -33,11 +39,42 @@ export default async function Index({ const { page } = searchParamsSchema.parse(searchParams); const { gameTypeName } = paramsSchema.parse(params); + let nameFilter: string[] | undefined; + let filteredCount: number | undefined; + + if (sharedHiddenParam(searchParams)) { + const [names, counts] = await Promise.all([ + prisma.$queryRawTyped( + listGameTypePlayersWithoutShared( + gameTypeName, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, + 100, + (page - 1) * 100 + ) + ), + prisma.$queryRawTyped( + countGameTypePlayersWithoutShared( + gameTypeName, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO + ) + ), + ]); + + nameFilter = names.map((row) => row.playerName); + filteredCount = Number(counts[0]?.count ?? 0); + } + const gameType = await prisma.gameType.findUnique({ select: { rankMethod: true, playerCount: true, playerInfoGameTypes: { + where: + nameFilter === undefined + ? undefined + : { playerName: { in: nameFilter } }, select: { rating: true, playTime: true, @@ -46,6 +83,8 @@ export default async function Index({ select: { clanName: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { gameServerState: { @@ -70,7 +109,7 @@ export default async function Index({ }, ], take: 100, - skip: (page - 1) * 100, + skip: nameFilter === undefined ? (page - 1) * 100 : 0, }, }, where: { @@ -84,7 +123,7 @@ export default async function Index({ return ( ({ rank: (page - 1) * 100 + index + 1, @@ -93,6 +132,8 @@ export default async function Index({ rating: playerInfoGameType.rating ?? undefined, playTime: playerInfoGameType.playTime, lastSeenAt: playerInfoGameType.player.lastSeenAt, + pollCount: playerInfoGameType.player.pollCount, + occurrenceCount: playerInfoGameType.player.occurrenceCount, gameServers: playerInfoGameType.player.gameServerStateClients.map((client) => ({ ip: client.gameServerState.gameServer?.ip ?? '', port: client.gameServerState.gameServer?.port ?? 0, diff --git a/apps/frontend/app/gametype/[gameTypeName]/map/[mapName]/page.tsx b/apps/frontend/app/gametype/[gameTypeName]/map/[mapName]/page.tsx index 25cde1e9..bb93c9f2 100644 --- a/apps/frontend/app/gametype/[gameTypeName]/map/[mapName]/page.tsx +++ b/apps/frontend/app/gametype/[gameTypeName]/map/[mapName]/page.tsx @@ -5,6 +5,12 @@ import prisma from '../../../../../utils/prisma'; import { paramsSchema, searchParamsSchema } from './schema'; import { notFound } from 'next/navigation'; import { z } from 'zod'; +import { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; +import { + countMapPlayersWithoutShared, + listMapPlayersWithoutShared, +} from '@prisma/client/sql'; +import { sharedHiddenParam } from '../../../../../utils/shared'; export async function generateMetadata({ params, @@ -32,6 +38,35 @@ export default async function Index({ const { page } = searchParamsSchema.parse(searchParams); const { gameTypeName, mapName } = paramsSchema.parse(params); + let nameFilter: string[] | undefined; + let filteredCount: number | undefined; + + if (sharedHiddenParam(searchParams)) { + const [names, counts] = await Promise.all([ + prisma.$queryRawTyped( + listMapPlayersWithoutShared( + mapName, + gameTypeName, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, + 100, + (page - 1) * 100 + ) + ), + prisma.$queryRawTyped( + countMapPlayersWithoutShared( + mapName, + gameTypeName, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO + ) + ), + ]); + + nameFilter = names.map((row) => row.playerName); + filteredCount = Number(counts[0]?.count ?? 0); + } + const map = await prisma.map.findUnique({ select: { gameType: { @@ -41,6 +76,10 @@ export default async function Index({ }, playerCount: true, playerInfoMaps: { + where: + nameFilter === undefined + ? undefined + : { playerName: { in: nameFilter } }, select: { rating: true, player: { @@ -48,6 +87,8 @@ export default async function Index({ name: true, clanName: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { gameServerState: { @@ -73,7 +114,7 @@ export default async function Index({ }, ], take: 100, - skip: (page - 1) * 100, + skip: nameFilter === undefined ? (page - 1) * 100 : 0, }, }, where: { @@ -90,7 +131,7 @@ export default async function Index({ return ( ({ rank: (page - 1) * 100 + index + 1, @@ -99,6 +140,8 @@ export default async function Index({ rating: playerInfoMap.rating ?? undefined, playTime: playerInfoMap.playTime, lastSeenAt: playerInfoMap.player.lastSeenAt, + pollCount: playerInfoMap.player.pollCount, + occurrenceCount: playerInfoMap.player.occurrenceCount, gameServers: playerInfoMap.player.gameServerStateClients.map((client) => ({ ip: client.gameServerState.gameServer?.ip ?? '', port: client.gameServerState.gameServer?.port ?? 0, diff --git a/apps/frontend/app/player/[playerName]/LayoutTabs.tsx b/apps/frontend/app/player/[playerName]/LayoutTabs.tsx index 68564b19..f52195d0 100644 --- a/apps/frontend/app/player/[playerName]/LayoutTabs.tsx +++ b/apps/frontend/app/player/[playerName]/LayoutTabs.tsx @@ -5,12 +5,13 @@ import { usePathname } from 'next/navigation'; import { encodeString } from '../../../utils/encoding'; export function LayoutTabs({ - playerName, mapCount, gameTypeCount, clanCount, + playerName, + clanCount, + teammateCount, }: { playerName: string; - mapCount: number; - gameTypeCount: number; clanCount: number; + teammateCount: number; }) { const urlPathname = `/player/${encodeString(playerName)}`; const pathname = usePathname(); @@ -18,18 +19,14 @@ export function LayoutTabs({ return ( + ); } diff --git a/apps/frontend/app/player/[playerName]/activity/page.tsx b/apps/frontend/app/player/[playerName]/activity/page.tsx new file mode 100644 index 00000000..cb830deb --- /dev/null +++ b/apps/frontend/app/player/[playerName]/activity/page.tsx @@ -0,0 +1,205 @@ +import { paramsSchema } from '../schema'; +import { z } from 'zod'; +import { notFound } from 'next/navigation'; +import { Fragment } from 'react'; +import { Metadata } from 'next'; +import prisma from '../../../../utils/prisma'; +import { List, ListCell } from '../../../../components/List'; +import { LastSeen } from '../../../../components/LastSeen'; +import { encodeIp, encodeString } from '../../../../utils/encoding'; +import { formatPlayTime } from '../../../../utils/format'; + +// One snapshot per poll cycle, same 5 minutes the rollups count. +const OBSERVATION_SECONDS = 5 * 60; +const RECENT_CLIENT_COUNT = 500; + +export async function generateMetadata({ + params, +}: { + params: z.infer; +}): Promise { + const { playerName } = paramsSchema.parse(params); + + return { + title: `Player ${playerName} - Activity`, + description: 'Servers a Teeworlds player was recently seen on', + alternates: { + canonical: `https://teerank.io/player/${encodeString(playerName)}/activity`, + }, + }; +} + +export default async function Index({ + params, +}: { + params: z.infer; +}) { + const { playerName } = paramsSchema.parse(params); + + const player = await prisma.player.findUnique({ + select: { + name: true, + + gameServerStateClients: { + select: { + gameServerState: { + select: { + gameServer: { + select: { + ip: true, + port: true, + }, + }, + }, + }, + }, + }, + }, + where: { name: playerName }, + }); + + if (player === null) { + return notFound(); + } + + const clients = await prisma.gameServerClient.findMany({ + where: { playerName }, + orderBy: { id: 'desc' }, + take: RECENT_CLIENT_COUNT, + select: { + inGame: true, + snapshot: { + select: { + createdAt: true, + name: true, + map: { + select: { + name: true, + gameTypeName: true, + }, + }, + gameServer: { + select: { + ip: true, + port: true, + }, + }, + }, + }, + }, + }); + + // One row per server, carrying its latest snapshot. + const servers = new Map< + string, + { + snapshot: (typeof clients)[number]['snapshot']; + playTime: number; + } + >(); + + for (const client of clients) { + const key = `${client.snapshot.gameServer.ip}:${client.snapshot.gameServer.port}`; + const server = servers.get(key); + const playTime = client.inGame ? OBSERVATION_SECONDS : 0; + + if (server === undefined) { + servers.set(key, { snapshot: client.snapshot, playTime }); + } else { + server.playTime += playTime; + + if (client.snapshot.createdAt > server.snapshot.createdAt) { + server.snapshot = client.snapshot; + } + } + } + + const rows = [...servers.entries()].sort( + (a, b) => + b[1].snapshot.createdAt.getTime() - a[1].snapshot.createdAt.getTime() + ); + + const onlineAddresses = new Set( + player.gameServerStateClients.map( + (client) => + `${client.gameServerState.gameServer.ip}:${client.gameServerState.gameServer.port}` + ) + ); + + if (servers.size === 0) { + return ( +

+ No recent activity — only the last couple of days are shown here. +

+ ); + } + + return ( + + {rows.map(([address, server]) => ( + + + + + + + + ))} + + ); +} diff --git a/apps/frontend/app/player/[playerName]/layout.tsx b/apps/frontend/app/player/[playerName]/layout.tsx index c766a065..c0ae3e18 100644 --- a/apps/frontend/app/player/[playerName]/layout.tsx +++ b/apps/frontend/app/player/[playerName]/layout.tsx @@ -9,7 +9,9 @@ import { LastSeen } from '../../../components/LastSeen'; import { formatPlayTime } from '../../../utils/format'; import { encodeString } from '../../../utils/encoding'; import { ActivityHeader } from '../../../components/ActivityHeader'; +import { SharedRatio } from '../../../components/SharedRatio'; import { getPlayerActivity } from '../../../utils/activity'; +import { countTeammates } from '../../../utils/teammates'; export default async function Index({ params, @@ -27,6 +29,8 @@ export default async function Index({ clanName: true, playTime: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { @@ -52,22 +56,13 @@ export default async function Index({ return notFound(); } - const [mapCount, gameTypeCount, clanCount, activity] = await Promise.all([ - prisma.playerInfoMap.count({ - where: { - playerName, - }, - }), - prisma.playerInfoGameType.count({ - where: { - playerName, - }, - }), + const [clanCount, teammateCount, activity] = await Promise.all([ prisma.clanPlayerInfo.count({ where: { playerName, }, }), + countTeammates(player.id), getPlayerActivity(player.id, { range: '1y' }), ]); @@ -81,7 +76,13 @@ export default async function Index({ > Player
-

{player.name}

+
+

{player.name}

+ +
{player.clanName !== null && ( @@ -104,6 +105,7 @@ export default async function Index({ .map((client) => client.gameServerState.gameServer) .filter((gameServer) => gameServer !== null)} lastSeenAt={player.lastSeenAt} + playerName={player.name} />
@@ -113,9 +115,8 @@ export default async function Index({ {children} diff --git a/apps/frontend/app/player/[playerName]/maps/page.tsx b/apps/frontend/app/player/[playerName]/maps/page.tsx index f97719d7..a394b8a0 100644 --- a/apps/frontend/app/player/[playerName]/maps/page.tsx +++ b/apps/frontend/app/player/[playerName]/maps/page.tsx @@ -1,68 +1,14 @@ import { paramsSchema } from '../schema'; import { z } from 'zod'; -import { notFound } from 'next/navigation'; -import { searchParamPageSchema } from '../../../../utils/page'; -import prisma from '../../../../utils/prisma'; -import { MapList } from '../../../../components/MapList'; +import { permanentRedirect } from 'next/navigation'; +import { encodeString } from '../../../../utils/encoding'; -export const metadata = { - title: 'Player - Maps', - description: 'A Teeworlds player', -}; - -export default async function Index({ +export default function Index({ params, - searchParams, }: { params: z.infer; - searchParams: { [key: string]: string | string[] | undefined }; }) { const { playerName } = paramsSchema.parse(params); - const { page } = searchParamPageSchema.parse(searchParams); - - const player = await prisma.player.findUnique({ - select: { - _count: { - select: { - playerInfoMaps: true, - }, - }, - - playerInfoMaps: { - select: { - map: { - select: { - name: true, - gameTypeName: true, - }, - }, - playTime: true, - }, - orderBy: { - playTime: 'desc', - }, - take: 100, - skip: (page - 1) * 100, - }, - }, - where: { - name: playerName, - }, - }); - - if (player === null) { - return notFound(); - } - return ( - ({ - rank: (page - 1) * 100 + index + 1, - name: playerInfoMap.map?.name ?? '', - gameTypeName: playerInfoMap.map?.gameTypeName ?? '', - playTime: playerInfoMap.playTime, - }))} - /> - ); + permanentRedirect(`/player/${encodeString(playerName)}?show=maps`); } diff --git a/apps/frontend/app/player/[playerName]/page.tsx b/apps/frontend/app/player/[playerName]/page.tsx index dc888aa3..5f4bcd8f 100644 --- a/apps/frontend/app/player/[playerName]/page.tsx +++ b/apps/frontend/app/player/[playerName]/page.tsx @@ -1,11 +1,14 @@ import { paramsSchema } from './schema'; import { z } from 'zod'; import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { Metadata } from 'next'; +import { Fragment } from 'react'; import { searchParamPageSchema } from '../../../utils/page'; import prisma from '../../../utils/prisma'; -import { GameTypeList } from '../../../components/GameTypeList'; -import { Metadata } from 'next'; +import { List, ListCell } from '../../../components/List'; import { encodeString } from '../../../utils/encoding'; +import { formatPlayTime } from '../../../utils/format'; export async function generateMetadata({ params, @@ -32,12 +35,14 @@ export default async function Index({ }) { const { playerName } = paramsSchema.parse(params); const { page } = searchParamPageSchema.parse(searchParams); + const showMaps = searchParams['show'] === 'maps'; const player = await prisma.player.findUnique({ select: { _count: { select: { playerInfoGameTypes: true, + playerInfoMaps: true, }, }, @@ -49,8 +54,25 @@ export default async function Index({ orderBy: { playTime: 'desc', }, - take: 100, - skip: (page - 1) * 100, + take: showMaps ? 0 : 100, + skip: showMaps ? 0 : (page - 1) * 100, + }, + + playerInfoMaps: { + select: { + map: { + select: { + name: true, + gameTypeName: true, + }, + }, + playTime: true, + }, + orderBy: { + playTime: 'desc', + }, + take: showMaps ? 100 : 0, + skip: showMaps ? (page - 1) * 100 : 0, }, }, where: { @@ -62,14 +84,78 @@ export default async function Index({ return notFound(); } - return ( - ({ - rank: (page - 1) * 100 + index + 1, - name: playerInfoGameType.gameTypeName ?? '', + const pathname = `/player/${encodeString(playerName)}`; + + const rows = showMaps + ? player.playerInfoMaps.map((playerInfoMap) => ({ + gameTypeName: playerInfoMap.map?.gameTypeName ?? '', + mapName: playerInfoMap.map?.name ?? '', + playTime: playerInfoMap.playTime, + })) + : player.playerInfoGameTypes.map((playerInfoGameType) => ({ + gameTypeName: playerInfoGameType.gameTypeName ?? '', + mapName: undefined, playTime: playerInfoGameType.playTime, - }))} - /> + })); + + const count = showMaps + ? player._count.playerInfoMaps + : player._count.playerInfoGameTypes; + + return ( + + Map + + {showMaps ? 'Hide' : 'Show'} + + + ), + expand: true, + }, + { + title: 'Playtime', + expand: false, + }, + ]} + pageCount={Math.ceil(count / 100)} + > + {rows.map((row, index) => ( + + + + + + ))} + ); } diff --git a/apps/frontend/app/player/[playerName]/playtime/page.tsx b/apps/frontend/app/player/[playerName]/playtime/page.tsx new file mode 100644 index 00000000..7cc3fa80 --- /dev/null +++ b/apps/frontend/app/player/[playerName]/playtime/page.tsx @@ -0,0 +1,17 @@ +import { paramsSchema } from '../schema'; +import { z } from 'zod'; +import { permanentRedirect } from 'next/navigation'; +import { encodeString } from '../../../../utils/encoding'; + +export default function Index({ + params, + searchParams, +}: { + params: z.infer; + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const { playerName } = paramsSchema.parse(params); + const query = searchParams['show'] === 'maps' ? '?show=maps' : ''; + + permanentRedirect(`/player/${encodeString(playerName)}${query}`); +} diff --git a/apps/frontend/app/player/[playerName]/teammates/page.tsx b/apps/frontend/app/player/[playerName]/teammates/page.tsx new file mode 100644 index 00000000..4e5193a6 --- /dev/null +++ b/apps/frontend/app/player/[playerName]/teammates/page.tsx @@ -0,0 +1,61 @@ +import { paramsSchema } from '../schema'; +import { z } from 'zod'; +import { notFound } from 'next/navigation'; +import { Metadata } from 'next'; +import prisma from '../../../../utils/prisma'; +import { searchParamPageSchema } from '../../../../utils/page'; +import { TeammateList } from '../../../../components/TeammateList'; +import { encodeString } from '../../../../utils/encoding'; +import { countTeammates, getTeammates } from '../../../../utils/teammates'; + +export async function generateMetadata({ + params, +}: { + params: z.infer; +}): Promise { + const { playerName } = paramsSchema.parse(params); + + return { + title: `Player ${playerName} - Teammates`, + description: 'Players a Teeworlds player spent the most time with', + alternates: { + canonical: `https://teerank.io/player/${encodeString(playerName)}/teammates`, + }, + }; +} + +export default async function Index({ + params, + searchParams, +}: { + params: z.infer; + searchParams: { [key: string]: string | string[] | undefined }; +}) { + const { playerName } = paramsSchema.parse(params); + const { page } = searchParamPageSchema.parse(searchParams); + + const player = await prisma.player.findUnique({ + select: { + id: true, + }, + where: { + name: playerName, + }, + }); + + if (player === null) { + return notFound(); + } + + const [teammates, teammateCount] = await Promise.all([ + getTeammates(player.id, { skip: (page - 1) * 100, take: 100 }), + countTeammates(player.id), + ]); + + return ( + + ); +} diff --git a/apps/frontend/app/search/page.tsx b/apps/frontend/app/search/page.tsx index 8400812a..7d41aed3 100644 --- a/apps/frontend/app/search/page.tsx +++ b/apps/frontend/app/search/page.tsx @@ -3,6 +3,7 @@ import { LayoutTabs } from './LayoutTabs'; import { Error } from './SearchError'; import { PlayerList } from '../../components/PlayerList'; import { search } from '../../utils/search'; +import { sharedHiddenParam } from '../../utils/shared'; export const metadata = { title: 'Search - Players', @@ -23,7 +24,9 @@ export default async function Index({ return ; } - const { players, clans, gameServers } = await search(query); + const { players, clans, gameServers } = await search(query, { + includeShared: !sharedHiddenParam(searchParams), + }); return ( @@ -38,6 +41,8 @@ export default async function Index({ rating: undefined, playTime: BigInt(player.playTime), lastSeenAt: player.lastSeenAt, + pollCount: player.pollCount, + occurrenceCount: player.occurrenceCount, gameServers: player.servers, }))} /> diff --git a/apps/frontend/components/EyeToggle.tsx b/apps/frontend/components/EyeToggle.tsx new file mode 100644 index 00000000..f0027404 --- /dev/null +++ b/apps/frontend/components/EyeToggle.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { twMerge } from 'tailwind-merge'; + +function EyeIcon({ crossed }: { crossed: boolean }) { + return ( + + + + {crossed && } + + ); +} + +export function EyeToggle({ + label, + title, + param, + value, + visibleWhenSet, + className, +}: { + label: string; + title: string; + param: string; + value: string; + visibleWhenSet: boolean; + className?: string; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const isSet = searchParams.get(param) === value; + const visible = visibleWhenSet ? isSet : !isSet; + + const toggle = () => { + const params = new URLSearchParams(searchParams.toString()); + + if (isSet) { + params.delete(param); + } else { + params.set(param, value); + } + + const queryString = params.toString(); + router.replace(queryString ? `${pathname}?${queryString}` : pathname, { + scroll: false, + }); + }; + + return ( + + ); +} diff --git a/apps/frontend/components/HeaderTabs.tsx b/apps/frontend/components/HeaderTabs.tsx index 2d38473d..a0af3a29 100644 --- a/apps/frontend/components/HeaderTabs.tsx +++ b/apps/frontend/components/HeaderTabs.tsx @@ -217,7 +217,8 @@ export function HeaderTabs({ label="Player" pathname={`/player/${encodeString(playerName)}`} extraPathnames={[ - `/player/${encodeString(playerName)}/maps`, + `/player/${encodeString(playerName)}/teammates`, + `/player/${encodeString(playerName)}/activity`, `/player/${encodeString(playerName)}/clans`, ]} /> diff --git a/apps/frontend/components/LastSeen.tsx b/apps/frontend/components/LastSeen.tsx index 2e7720c8..13dd421f 100644 --- a/apps/frontend/components/LastSeen.tsx +++ b/apps/frontend/components/LastSeen.tsx @@ -1,12 +1,25 @@ -import { intervalToDuration } from 'date-fns'; +import { Duration, intervalToDuration } from 'date-fns'; import Link from 'next/link'; import { twMerge } from 'tailwind-merge'; import { formatDurationShort } from '../utils/format'; -import { encodeIp } from '../utils/encoding'; +import { encodeIp, encodeString } from '../utils/encoding'; + +export function durationClassName(duration: Duration) { + if ((duration.years ?? 0) > 0 || (duration.months ?? 0) > 0) { + return 'text-[#c6c6c6]'; + } else if ((duration.weeks ?? 0) > 0 || (duration.days ?? 0) > 0) { + return 'text-[#a7a7a7]'; + } else if ((duration.hours ?? 0) > 0) { + return 'text-[#7fa764]'; + } else { + return 'text-[#56a721]'; + } +} export function LastSeen({ lastSeenAt, gameServers, + playerName, className: propClassName, }: { lastSeenAt: Date; @@ -14,44 +27,44 @@ export function LastSeen({ ip: string; port: number; }[]; + playerName?: string; className?: string; }) { if (gameServers.length > 0) { + const pathname = + gameServers.length > 1 && playerName !== undefined + ? `/player/${encodeString(playerName)}/activity` + : `/server/${encodeIp(gameServers[0].ip)}/${gameServers[0].port}`; + return ( Online ); } else { - const { years, months, weeks, days, hours } = intervalToDuration({ - start: lastSeenAt, - end: new Date(), - }); - - let className; + const duration = intervalToDuration({ start: lastSeenAt, end: new Date() }); + const className = twMerge( + 'truncate', + durationClassName(duration), + propClassName + ); - if ((years ?? 0) > 0 || (months ?? 0) > 0) { - className = 'text-[#c6c6c6]'; - } else if ((weeks ?? 0) > 0 || (days ?? 0) > 0) { - className = 'text-[#a7a7a7]'; - } else if ((hours ?? 0) > 0) { - className = 'text-[#7fa764]'; - } else { - className = 'text-[#56a721]'; + if (playerName !== undefined) { + return ( + + {formatDurationShort(duration)} + + ); } - return ( - - {formatDurationShort( - intervalToDuration({ start: lastSeenAt, end: new Date() }) - )} - - ); + return {formatDurationShort(duration)}; } } diff --git a/apps/frontend/components/List.tsx b/apps/frontend/components/List.tsx index b3464832..148ff320 100644 --- a/apps/frontend/components/List.tsx +++ b/apps/frontend/components/List.tsx @@ -5,7 +5,7 @@ import { Pagination } from './Pagination'; import { Suspense } from 'react'; type Column = { - title: string; + title: React.ReactNode; expand: boolean; }; @@ -31,8 +31,8 @@ export function List({ gridTemplateColumns, }} > - {columns.map((column) => ( - + {columns.map((column, index) => ( + {column.title} ))} diff --git a/apps/frontend/components/PlayerList.tsx b/apps/frontend/components/PlayerList.tsx index e48df081..d38f4603 100644 --- a/apps/frontend/components/PlayerList.tsx +++ b/apps/frontend/components/PlayerList.tsx @@ -1,8 +1,12 @@ import { RankMethod } from '@prisma/client'; +import { Fragment, Suspense } from 'react'; +import Link from 'next/link'; +import { twMerge } from 'tailwind-merge'; import { formatInteger, formatPlayTime } from '../utils/format'; import { List, ListCell } from './List'; import { LastSeen } from './LastSeen'; -import { Fragment } from 'react'; +import { SharedRatio } from './SharedRatio'; +import { EyeToggle } from './EyeToggle'; import { encodeString } from '../utils/encoding'; export function PlayerList({ @@ -10,6 +14,7 @@ export function PlayerList({ rankMethod, playerCount, showLastSeen, + showInactiveToggle, }: { players: { rank: number; @@ -19,6 +24,8 @@ export function PlayerList({ rating?: number; playTime: bigint; lastSeenAt: Date; + pollCount: number; + occurrenceCount: number; gameServers: { ip: string; port: number; @@ -27,14 +34,39 @@ export function PlayerList({ rankMethod: RankMethod | null; playerCount?: number; showLastSeen?: boolean; + showInactiveToggle?: boolean; }) { - const columns = [ + const columns: { title: React.ReactNode; expand: boolean }[] = [ { title: '', expand: false, }, { - title: 'Name', + title: ( + <> + Name + + + {showInactiveToggle && ( + + )} + + + ), expand: true, }, { @@ -79,13 +111,24 @@ export function PlayerList({ return ( - + + + {player.isActiveClan === false + ? `${player.name} (Past)` + : player.name} + + + )} - + {showLastSeen && ( )} diff --git a/apps/frontend/components/SharedRatio.tsx b/apps/frontend/components/SharedRatio.tsx new file mode 100644 index 00000000..ecb2123c --- /dev/null +++ b/apps/frontend/components/SharedRatio.tsx @@ -0,0 +1,34 @@ +import { twMerge } from 'tailwind-merge'; +import { isStubName } from '@teerank/teerank'; + +export function SharedRatio({ + pollCount, + occurrenceCount, + className, +}: { + pollCount: number; + occurrenceCount: number; + className?: string; +}) { + if (!isStubName(pollCount, occurrenceCount)) { + return null; + } + + const ratio = occurrenceCount / pollCount; + + return ( + + + + {ratio.toFixed(1)} + + ); +} diff --git a/apps/frontend/components/Tabs.tsx b/apps/frontend/components/Tabs.tsx index c7453953..c20fa7b8 100644 --- a/apps/frontend/components/Tabs.tsx +++ b/apps/frontend/components/Tabs.tsx @@ -18,7 +18,7 @@ export function Tab({ href, }: { label: string; - count: number; + count?: number; isActive: boolean; href: UrlObject; }) { @@ -32,9 +32,11 @@ export function Tab({ const content = ( <> {label} - - {formatInteger(count)} - + {count !== undefined && ( + + {formatInteger(count)} + + )} ); diff --git a/apps/frontend/components/TeammateList.tsx b/apps/frontend/components/TeammateList.tsx new file mode 100644 index 00000000..d714eed5 --- /dev/null +++ b/apps/frontend/components/TeammateList.tsx @@ -0,0 +1,85 @@ +import { Fragment } from 'react'; +import { intervalToDuration } from 'date-fns'; +import { List, ListCell } from './List'; +import { durationClassName, LastSeen } from './LastSeen'; +import { encodeString } from '../utils/encoding'; +import { formatDurationShort, formatPlayTime } from '../utils/format'; +import { Teammate } from '../utils/teammates'; + +export function TeammateList({ + teammates, + pageCount, +}: { + teammates: Teammate[]; + pageCount?: number; +}) { + return ( + + {teammates.map((teammate) => { + const lastTogether = intervalToDuration({ + start: teammate.lastPlayedAt, + end: new Date(), + }); + + return ( + + + + + + + + ); + })} + + ); +} diff --git a/apps/frontend/utils/search.ts b/apps/frontend/utils/search.ts index d6d1bfdb..21fa7c3a 100644 --- a/apps/frontend/utils/search.ts +++ b/apps/frontend/utils/search.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; import prisma from './prisma'; import { searchClans, searchGameServers, searchPlayers } from '@prisma/client/sql'; @@ -7,12 +8,14 @@ const resultServerSchema = z.object({ port: z.number(), }); -export async function search(query: string) { +export async function search(query: string, { includeShared = true } = {}) { query = query.replace(/_/g, '\\_').replace(/%/g, '\\%'); console.time('search players'); - const players = await prisma.$queryRawTyped(searchPlayers(`%${query}%`)).then((players) => { + const players = await prisma.$queryRawTyped( + searchPlayers(`%${query}%`, includeShared, STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO) + ).then((players) => { return players.map((player) => ({ ...player, servers: player.servers?.map((server) => resultServerSchema.parse(server)) ?? [], diff --git a/apps/frontend/utils/shared.ts b/apps/frontend/utils/shared.ts new file mode 100644 index 00000000..7e636c91 --- /dev/null +++ b/apps/frontend/utils/shared.ts @@ -0,0 +1,5 @@ +export function sharedHiddenParam(searchParams: { + [key: string]: string | string[] | undefined; +}) { + return searchParams['shared'] === 'hidden'; +} diff --git a/apps/frontend/utils/teammates.ts b/apps/frontend/utils/teammates.ts new file mode 100644 index 00000000..1beecf86 --- /dev/null +++ b/apps/frontend/utils/teammates.ts @@ -0,0 +1,87 @@ +import { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; +import { + countPlayerPartnersWithoutShared, + listPlayerPartnersWithoutShared, +} from '@prisma/client/sql'; +import prisma from './prisma'; + +export type Teammate = { + name: string; + clan: string | null; + lastSeenAt: Date; + playTime: number; + lastPlayedAt: Date; + gameServers: { ip: string; port: number }[]; +}; + +export async function countTeammates(playerId: number) { + const counts = await prisma.$queryRawTyped( + countPlayerPartnersWithoutShared( + playerId, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO + ) + ); + + return Number(counts[0]?.count ?? 0); +} + +export async function getTeammates( + playerId: number, + { skip, take }: { skip: number; take: number } +): Promise { + const partnerRows = await prisma.$queryRawTyped( + listPlayerPartnersWithoutShared( + playerId, + STUB_MIN_POLL_COUNT, + STUB_OCCURRENCE_RATIO, + take, + skip + ) + ); + + const partners = await prisma.player.findMany({ + where: { + id: { in: partnerRows.map((row) => row.partnerId) }, + }, + select: { + id: true, + name: true, + clanName: true, + lastSeenAt: true, + gameServerStateClients: { + select: { + gameServerState: { + select: { + gameServer: { + select: { ip: true, port: true }, + }, + }, + }, + }, + }, + }, + }); + + const partnersById = new Map(partners.map((partner) => [partner.id, partner])); + + return partnerRows.flatMap((row) => { + const partner = partnersById.get(row.partnerId); + + if (partner === undefined) { + return []; + } + + return { + name: partner.name, + clan: partner.clanName, + lastSeenAt: partner.lastSeenAt, + playTime: row.playTime, + lastPlayedAt: row.lastPlayedAt, + gameServers: partner.gameServerStateClients.map((client) => ({ + ip: client.gameServerState.gameServer.ip, + port: client.gameServerState.gameServer.port, + })), + }; + }); +} diff --git a/apps/worker/src/rollup/aggregateDay.ts b/apps/worker/src/rollup/aggregateDay.ts index f13e1d6e..a39ff2fd 100644 --- a/apps/worker/src/rollup/aggregateDay.ts +++ b/apps/worker/src/rollup/aggregateDay.ts @@ -1,8 +1,11 @@ import { millisecondsInHour } from "date-fns"; +import { getEnvInt } from "@teerank/teerank"; import { removeDuplicatedClients } from "../utils"; export const OBSERVATION_SECONDS = 5 * 60; +export const PARTNER_DAY_THRESHOLD_SECONDS = getEnvInt('PARTNER_DAY_THRESHOLD_SECONDS', 3600); + export type RollupSnapshot = { createdAt: Date; gameServerId: number; @@ -17,11 +20,17 @@ export type RollupSnapshot = { }; export type DayRollup = { - players: { playerName: string; playTime: number }[]; + players: { + playerName: string; + playTime: number; + pollCount: number; + occurrenceCount: number; + }[]; serverDays: { gameServerId: number; avgClients: number; maxClients: number }[]; maps: { mapId: number; playTime: number; playerCount: number }[]; gameTypes: { gameTypeName: string; playTime: number; playerCount: number }[]; clans: { clanName: string; playTime: number; playerCount: number }[]; + partners: { playerName: string; partnerName: string; playTime: number }[]; }; type ServerHourAggregate = { @@ -36,6 +45,12 @@ type PresenceAggregate = { players: Set; }; +type PlayerAggregate = { + playTime: number; + occurrenceCount: number; + polls: Set; +}; + function getOrCreate(map: Map, key: K, create: () => V): V { let value = map.get(key); @@ -51,12 +66,19 @@ function newPresence(): PresenceAggregate { return { playTime: 0, players: new Set() }; } +function newPlayerAggregate(): PlayerAggregate { + return { playTime: 0, occurrenceCount: 0, polls: new Set() }; +} + export class DayAggregator { - private players = new Map(); + private players = new Map(); private serverHours = new Map(); private maps = new Map(); private gameTypes = new Map(); private clans = new Map(); + private partners = new Map(); + + constructor(private partnerThresholdSeconds = PARTNER_DAY_THRESHOLD_SECONDS) {} addSnapshot(snapshot: RollupSnapshot) { const clients = removeDuplicatedClients(snapshot.clients); @@ -84,10 +106,18 @@ export class DayAggregator { map.playTime += inGameCount * OBSERVATION_SECONDS; gameType.playTime += inGameCount * OBSERVATION_SECONDS; + // Snapshots taken in the same observation window count as one poll, so a + // name on several servers at once has more occurrences than polls. + const poll = Math.floor(snapshot.createdAt.getTime() / (OBSERVATION_SECONDS * 1000)); + for (const client of clients) { const playTime = client.inGame ? OBSERVATION_SECONDS : 0; - this.players.set(client.playerName, (this.players.get(client.playerName) ?? 0) + playTime); + const player = getOrCreate(this.players, client.playerName, newPlayerAggregate); + player.playTime += playTime; + player.occurrenceCount += 1; + player.polls.add(poll); + map.players.add(client.playerName); gameType.players.add(client.playerName); @@ -97,6 +127,18 @@ export class DayAggregator { clan.players.add(client.playerName); } } + + const inGameNames = clients + .filter((client) => client.inGame) + .map((client) => client.playerName) + .sort(); + + for (let i = 0; i < inGameNames.length; i++) { + for (let j = i + 1; j < inGameNames.length; j++) { + const key = `${inGameNames[i]}\0${inGameNames[j]}`; + this.partners.set(key, (this.partners.get(key) ?? 0) + OBSERVATION_SECONDS); + } + } } finalize(): DayRollup { @@ -117,9 +159,11 @@ export class DayAggregator { } return { - players: [...this.players.entries()].map(([playerName, playTime]) => ({ + players: [...this.players.entries()].map(([playerName, aggregate]) => ({ playerName, - playTime, + playTime: aggregate.playTime, + pollCount: aggregate.polls.size, + occurrenceCount: aggregate.occurrenceCount, })), serverDays: [...serverDays.entries()].map(([gameServerId, aggregate]) => ({ @@ -148,6 +192,13 @@ export class DayAggregator { playTime: aggregate.playTime, playerCount: aggregate.players.size, })), + + partners: [...this.partners.entries()] + .filter(([, playTime]) => playTime >= this.partnerThresholdSeconds) + .map(([key, playTime]) => { + const [playerName, partnerName] = key.split('\0'); + return { playerName, partnerName, playTime }; + }), }; } } diff --git a/apps/worker/src/rollup/writeDayRollup.ts b/apps/worker/src/rollup/writeDayRollup.ts index 053ce648..ec5f81c9 100644 --- a/apps/worker/src/rollup/writeDayRollup.ts +++ b/apps/worker/src/rollup/writeDayRollup.ts @@ -1,6 +1,7 @@ import { chunk } from "lodash"; import { minutesToMilliseconds } from "date-fns"; -import { formatUtcDay } from "@teerank/teerank"; +import { formatUtcDay, isStubName } from "@teerank/teerank"; +import { incrementPlayerPollCounts, upsertPlayerPartners } from "@prisma/client/sql"; import { prisma } from "../prisma"; import { DayRollup } from "./aggregateDay"; import { ensureRollupPartitions } from "./partitions"; @@ -26,14 +27,25 @@ async function lookupIds( export async function writeDayRollup(day: Date, rollup: DayRollup) { await ensureRollupPartitions(day); + const stubPlayerIds = new Set(); + const [playerIds, clanIds, gameTypeIds] = await Promise.all([ lookupIds( rollup.players.map((row) => row.playerName), - (names) => - prisma.player.findMany({ + async (names) => { + const players = await prisma.player.findMany({ where: { name: { in: names } }, - select: { name: true, id: true }, - }) + select: { name: true, id: true, pollCount: true, occurrenceCount: true }, + }); + + for (const player of players) { + if (isStubName(player.pollCount, player.occurrenceCount)) { + stubPlayerIds.add(player.id); + } + } + + return players; + } ), lookupIds( rollup.clans.map((row) => row.clanName), @@ -57,20 +69,24 @@ export async function writeDayRollup(day: Date, rollup: DayRollup) { // rollup has no string columns to keep them under. let droppedRows = 0; - const playerRows = rollup.players.flatMap((row) => { + const playerRows: { day: Date; playerId: number; playTime: number }[] = []; + const pollCountRows: { playerId: number; pollCount: number; occurrenceCount: number }[] = []; + + for (const row of rollup.players) { const playerId = playerIds.get(row.playerName); if (playerId === undefined) { droppedRows += 1; - return []; + continue; } - return { - day, + playerRows.push({ day, playerId, playTime: row.playTime }); + pollCountRows.push({ playerId, - playTime: row.playTime, - }; - }); + pollCount: row.pollCount, + occurrenceCount: row.occurrenceCount, + }); + } const serverRows = rollup.serverDays.map((row) => ({ day, @@ -118,16 +134,41 @@ export async function writeDayRollup(day: Date, rollup: DayRollup) { }; }); + // A stub name is many people, so pairs involving one are meaningless. + const partnerRows = rollup.partners.flatMap((row) => { + const playerId = playerIds.get(row.playerName); + const partnerId = playerIds.get(row.partnerName); + + if (playerId === undefined || partnerId === undefined) { + droppedRows += 1; + return []; + } + + if (stubPlayerIds.has(playerId) || stubPlayerIds.has(partnerId)) { + return []; + } + + return [ + { playerId, partnerId, playTime: row.playTime }, + { playerId: partnerId, partnerId: playerId, playTime: row.playTime }, + ]; + }); + // Inserting a day in key order keeps the btrees ~90% full instead of the // ~70% random inserts leave; the reverse indexes lead with the same id. playerRows.sort((a, b) => a.playerId - b.playerId); + pollCountRows.sort((a, b) => a.playerId - b.playerId); serverRows.sort((a, b) => a.gameServerId - b.gameServerId); mapRows.sort((a, b) => a.mapId - b.mapId); gameTypeRows.sort((a, b) => a.gameTypeId - b.gameTypeId); clanRows.sort((a, b) => a.clanId - b.clanId); + partnerRows.sort((a, b) => a.playerId - b.playerId || a.partnerId - b.partnerId); await prisma.$transaction( async (tx) => { + const alreadyRolledUp = + (await tx.globalDay.findUnique({ where: { day }, select: { day: true } })) !== null; + await tx.globalDay.upsert({ where: { day }, create: { day, playerCount: playerRows.length }, @@ -155,6 +196,29 @@ export async function writeDayRollup(day: Date, rollup: DayRollup) { for (const rows of chunk(clanRows, INSERT_CHUNK_SIZE)) { await tx.clanDay.createMany({ data: rows }); } + + if (!alreadyRolledUp) { + for (const rows of chunk(partnerRows, INSERT_CHUNK_SIZE)) { + await tx.$queryRawTyped( + upsertPlayerPartners( + rows.map((row) => row.playerId), + rows.map((row) => row.partnerId), + rows.map((row) => row.playTime), + day + ) + ); + } + + for (const rows of chunk(pollCountRows, INSERT_CHUNK_SIZE)) { + await tx.$queryRawTyped( + incrementPlayerPollCounts( + rows.map((row) => row.playerId), + rows.map((row) => row.pollCount), + rows.map((row) => row.occurrenceCount) + ) + ); + } + } }, { timeout: minutesToMilliseconds(5), maxWait: minutesToMilliseconds(1) } ); @@ -163,7 +227,8 @@ export async function writeDayRollup(day: Date, rollup: DayRollup) { console.log( `Rolled up ${dayLabel}: ${playerRows.length} PlayerDay, ` + `${serverRows.length} ServerDay, ${mapRows.length} MapDay, ` + - `${gameTypeRows.length} GameTypeDay, ${clanRows.length} ClanDay` + + `${gameTypeRows.length} GameTypeDay, ${clanRows.length} ClanDay, ` + + `${partnerRows.length} PlayerPartner` + (droppedRows > 0 ? ` (${droppedRows} rows dropped: unresolved names)` : '') ); } diff --git a/apps/worker/src/workers/fillClanActivePlayerCount.test.ts b/apps/worker/src/workers/fillClanActivePlayerCount.test.ts index 0e74cca5..09559cbd 100644 --- a/apps/worker/src/workers/fillClanActivePlayerCount.test.ts +++ b/apps/worker/src/workers/fillClanActivePlayerCount.test.ts @@ -26,6 +26,8 @@ describe('fillClanActivePlayerCount', () => { playTime: BigInt(0), clanName: 'clan1', lastSeenAt: new Date(), + pollCount: 0, + occurrenceCount: 0, _count: { id: 101 }, _avg: { id: 101 }, @@ -57,6 +59,8 @@ describe('fillClanActivePlayerCount', () => { playTime: BigInt(0), clanName: 'clan1', lastSeenAt: new Date(), + pollCount: 0, + occurrenceCount: 0, _count: { id: 1 }, _avg: { id: 1 }, @@ -99,6 +103,8 @@ describe('fillClanActivePlayerCount', () => { playTime: BigInt(0), clanName: 'clan2', lastSeenAt: new Date(), + pollCount: 0, + occurrenceCount: 0, _count: { id: 1 }, _avg: { id: 1 }, diff --git a/apps/worker/src/workers/rollupDay.test.ts b/apps/worker/src/workers/rollupDay.test.ts index 2b385585..a48d0c4b 100644 --- a/apps/worker/src/workers/rollupDay.test.ts +++ b/apps/worker/src/workers/rollupDay.test.ts @@ -32,7 +32,9 @@ describe('DayAggregator', () => { const rollup = aggregator.finalize(); - expect(rollup.players).toEqual([{ playerName: 'player0', playTime: OBSERVATION_SECONDS }]); + expect(rollup.players).toEqual([ + { playerName: 'player0', playTime: OBSERVATION_SECONDS, pollCount: 1, occurrenceCount: 1 }, + ]); expect(rollup.serverDays).toEqual([{ gameServerId: 1, avgClients: 1, maxClients: 1 }]); expect(rollup.maps).toEqual([{ mapId: 1, playTime: OBSERVATION_SECONDS, playerCount: 1 }]); expect(rollup.gameTypes).toEqual([ @@ -49,7 +51,9 @@ describe('DayAggregator', () => { const rollup = aggregator.finalize(); - expect(rollup.players).toEqual([{ playerName: 'player0', playTime: 0 }]); + expect(rollup.players).toEqual([ + { playerName: 'player0', playTime: 0, pollCount: 1, occurrenceCount: 1 }, + ]); expect(rollup.maps[0]).toEqual({ mapId: 1, playTime: 0, playerCount: 1 }); expect(rollup.clans[0]).toEqual({ clanName: 'clan0', playTime: 0, playerCount: 1 }); // A spectator is still a connected client. @@ -62,7 +66,22 @@ describe('DayAggregator', () => { const rollup = aggregator.finalize(); - expect(rollup.players).toEqual([{ playerName: 'player0', playTime: OBSERVATION_SECONDS }]); + expect(rollup.players).toEqual([ + { playerName: 'player0', playTime: OBSERVATION_SECONDS, pollCount: 1, occurrenceCount: 1 }, + ]); + }); + + test('a name on two servers in one poll has more occurrences than polls', () => { + const aggregator = new DayAggregator(); + aggregator.addSnapshot(newSnapshot(0, [newClient('player0')])); + aggregator.addSnapshot(newSnapshot(0, [newClient('player0')], { gameServerId: 2 })); + aggregator.addSnapshot(newSnapshot(5, [newClient('player0')])); + + const rollup = aggregator.finalize(); + + expect(rollup.players).toEqual([ + { playerName: 'player0', playTime: 3 * OBSERVATION_SECONDS, pollCount: 2, occurrenceCount: 3 }, + ]); }); test('servers with only empty snapshots are skipped', () => { @@ -89,6 +108,28 @@ describe('DayAggregator', () => { expect(rollup.serverDays).toEqual([{ gameServerId: 1, avgClients: 3, maxClients: 4 }]); }); + test('partners accumulate shared time, below-threshold pairs are dropped', () => { + const aggregator = new DayAggregator(2 * OBSERVATION_SECONDS); + aggregator.addSnapshot( + newSnapshot(0, [newClient('player1'), newClient('player0'), newClient('player2')]) + ); + aggregator.addSnapshot(newSnapshot(5, [newClient('player0'), newClient('player1')])); + + const rollup = aggregator.finalize(); + + expect(rollup.partners).toEqual([ + { playerName: 'player0', partnerName: 'player1', playTime: 2 * OBSERVATION_SECONDS }, + ]); + }); + + test('spectators and players on other servers are not partners', () => { + const aggregator = new DayAggregator(OBSERVATION_SECONDS); + aggregator.addSnapshot(newSnapshot(0, [newClient('player0'), newClient('player1', null, false)])); + aggregator.addSnapshot(newSnapshot(0, [newClient('player2')], { gameServerId: 2 })); + + expect(aggregator.finalize().partners).toEqual([]); + }); + test('distinct players across snapshots and gametypes', () => { const aggregator = new DayAggregator(); aggregator.addSnapshot(newSnapshot(0, [newClient('player0', 'clan0')])); @@ -103,8 +144,8 @@ describe('DayAggregator', () => { const rollup = aggregator.finalize(); expect(rollup.players).toEqual([ - { playerName: 'player0', playTime: 2 * OBSERVATION_SECONDS }, - { playerName: 'player1', playTime: OBSERVATION_SECONDS }, + { playerName: 'player0', playTime: 2 * OBSERVATION_SECONDS, pollCount: 2, occurrenceCount: 2 }, + { playerName: 'player1', playTime: OBSERVATION_SECONDS, pollCount: 1, occurrenceCount: 1 }, ]); expect(rollup.gameTypes).toEqual([ { gameTypeName: 'CTF', playTime: OBSERVATION_SECONDS, playerCount: 1 }, @@ -118,7 +159,9 @@ describe('DayAggregator', () => { describe('rollupDay', () => { const mockLookups = () => { - prismaMock.player.findMany.mockResolvedValue([{ name: 'player0', id: 11 }] as never); + prismaMock.player.findMany.mockResolvedValue([ + { name: 'player0', id: 11, pollCount: 0, occurrenceCount: 0 }, + ] as never); prismaMock.clan.findMany.mockResolvedValue([{ name: 'clan0', id: 21 }] as never); prismaMock.gameType.findMany.mockResolvedValue([{ name: 'CTF', id: 31 }] as never); prismaMock.$queryRawTyped.mockResolvedValue([] as never); @@ -186,6 +229,82 @@ describe('rollupDay', () => { }); }); + // Two players sharing 12 snapshots reach the default one-hour threshold. + const mockSharedSnapshots = (count = 12) => { + prismaMock.gameServerSnapshot.findMany.mockResolvedValueOnce( + Array.from({ length: count }, (_, i) => ({ + id: i + 1, + createdAt: addMinutes(day, 5 * i), + gameServerId: 1, + mapId: 1, + numClients: 2, + map: { gameTypeName: 'CTF' }, + clients: [ + { playerName: 'player0', clanName: null, inGame: true }, + { playerName: 'player1', clanName: null, inGame: true }, + ], + })) as never + ); + }; + + const partnerUpsertCalls = () => + prismaMock.$queryRawTyped.mock.calls.filter(([query]) => + (query as unknown as { sql: string }).sql.includes('PlayerPartner') + ); + + test('writes partner pairs in both directions', async () => { + prismaMock.globalDay.findUnique.mockResolvedValue(null); + mockSharedSnapshots(); + mockLookups(); + prismaMock.player.findMany.mockResolvedValue([ + { name: 'player0', id: 11, pollCount: 0, occurrenceCount: 0 }, + { name: 'player1', id: 12, pollCount: 0, occurrenceCount: 0 }, + ] as never); + + await rollupDay({ day: '2026-08-19' }); + + const calls = partnerUpsertCalls(); + expect(calls).toHaveLength(1); + expect((calls[0][0] as unknown as { values: unknown[] }).values).toEqual([ + [11, 12], + [12, 11], + [3600, 3600], + day, + ]); + }); + + test('pairs involving a stub name are not written', async () => { + prismaMock.globalDay.findUnique.mockResolvedValue(null); + mockSharedSnapshots(); + mockLookups(); + prismaMock.player.findMany.mockResolvedValue([ + { name: 'player0', id: 11, pollCount: 0, occurrenceCount: 0 }, + { name: 'player1', id: 12, pollCount: 100, occurrenceCount: 200 }, + ] as never); + + await rollupDay({ day: '2026-08-19' }); + + expect(partnerUpsertCalls()).toHaveLength(0); + expect(prismaMock.playerDay.createMany).toHaveBeenCalled(); + }); + + test('re-rolling a day does not increment partner totals again', async () => { + prismaMock.globalDay.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ day } as never); + mockSharedSnapshots(); + mockLookups(); + prismaMock.player.findMany.mockResolvedValue([ + { name: 'player0', id: 11, pollCount: 0, occurrenceCount: 0 }, + { name: 'player1', id: 12, pollCount: 0, occurrenceCount: 0 }, + ] as never); + + await rollupDay({ day: '2026-08-19' }); + + expect(partnerUpsertCalls()).toHaveLength(0); + expect(prismaMock.playerDay.createMany).toHaveBeenCalled(); + }); + test('players that no longer exist are dropped', async () => { prismaMock.globalDay.findUnique.mockResolvedValue(null); prismaMock.gameServerSnapshot.findMany.mockResolvedValue([ diff --git a/apps/worker/src/workers/updateGlobalCounts.test.ts b/apps/worker/src/workers/updateGlobalCounts.test.ts index 56e0146c..c3a8d126 100644 --- a/apps/worker/src/workers/updateGlobalCounts.test.ts +++ b/apps/worker/src/workers/updateGlobalCounts.test.ts @@ -12,6 +12,8 @@ const mockPlayer: Player = { lastSeenAt: new Date(), clanName: "test", playTime: BigInt(1), + pollCount: 0, + occurrenceCount: 0, } const mockClan: Clan = { diff --git a/libs/prisma/prisma/migrations/20260825000000_teammates_and_stub_names/migration.sql b/libs/prisma/prisma/migrations/20260825000000_teammates_and_stub_names/migration.sql new file mode 100644 index 00000000..e9ac5ab3 --- /dev/null +++ b/libs/prisma/prisma/migrations/20260825000000_teammates_and_stub_names/migration.sql @@ -0,0 +1,13 @@ +ALTER TABLE "Player" + ADD COLUMN "pollCount" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "occurrenceCount" INTEGER NOT NULL DEFAULT 0; + +-- Not partitioned: rows are upserted in place, not appended per day. +CREATE TABLE "PlayerPartner" ( + "playerId" INTEGER NOT NULL, + "partnerId" INTEGER NOT NULL, + "playTime" INTEGER NOT NULL, + "lastPlayedAt" DATE NOT NULL, + + CONSTRAINT "PlayerPartner_pkey" PRIMARY KEY ("playerId", "partnerId") +); diff --git a/libs/prisma/prisma/migrations/20260825000001_game_server_client_player_name_index/migration.sql b/libs/prisma/prisma/migrations/20260825000001_game_server_client_player_name_index/migration.sql new file mode 100644 index 00000000..3aa0a414 --- /dev/null +++ b/libs/prisma/prisma/migrations/20260825000001_game_server_client_player_name_index/migration.sql @@ -0,0 +1,3 @@ +-- Single statement on purpose: CONCURRENTLY cannot run inside the +-- transaction Prisma wraps multi-statement migrations in. +CREATE INDEX CONCURRENTLY "GameServerClient_playerName_id_idx" ON "GameServerClient"("playerName", "id"); diff --git a/libs/prisma/prisma/schema.prisma b/libs/prisma/prisma/schema.prisma index 206a1a09..60b0f4ff 100644 --- a/libs/prisma/prisma/schema.prisma +++ b/libs/prisma/prisma/schema.prisma @@ -100,6 +100,9 @@ model Player { playTime BigInt @default(0) + pollCount Int @default(0) + occurrenceCount Int @default(0) + gameServerClients GameServerClient[] gameServerStateClients GameServerStateClient[] clanPlayerInfos ClanPlayerInfo[] @@ -211,6 +214,7 @@ model GameServerClient { inGame Boolean @@index([snapshotId]) + @@index([playerName, id]) } model GameServerSnapshot { @@ -374,3 +378,12 @@ model ClanDay { @@id([clanId, day]) } + +model PlayerPartner { + playerId Int + partnerId Int + playTime Int // seconds + lastPlayedAt DateTime @db.Date + + @@id([playerId, partnerId]) +} diff --git a/libs/prisma/prisma/sql/countClanPlayersWithoutShared.sql b/libs/prisma/prisma/sql/countClanPlayersWithoutShared.sql new file mode 100644 index 00000000..48a1cbd9 --- /dev/null +++ b/libs/prisma/prisma/sql/countClanPlayersWithoutShared.sql @@ -0,0 +1,6 @@ +SELECT count(*) AS "count" +FROM "ClanPlayerInfo" AS cpi +JOIN "Player" AS p ON p."name" = cpi."playerName" +WHERE cpi."clanName" = $1 + AND ($2::bool OR p."clanName" = $1) + AND (p."pollCount" < $3 OR p."occurrenceCount"::float8 < p."pollCount" * $4::float8); diff --git a/libs/prisma/prisma/sql/countGameTypePlayersWithoutShared.sql b/libs/prisma/prisma/sql/countGameTypePlayersWithoutShared.sql new file mode 100644 index 00000000..18c5f3a2 --- /dev/null +++ b/libs/prisma/prisma/sql/countGameTypePlayersWithoutShared.sql @@ -0,0 +1,5 @@ +SELECT count(*) AS "count" +FROM "PlayerInfoGameType" AS pig +JOIN "Player" AS p ON p."name" = pig."playerName" +WHERE pig."gameTypeName" = $1 + AND (p."pollCount" < $2 OR p."occurrenceCount"::float8 < p."pollCount" * $3::float8); diff --git a/libs/prisma/prisma/sql/countMapPlayersWithoutShared.sql b/libs/prisma/prisma/sql/countMapPlayersWithoutShared.sql new file mode 100644 index 00000000..55268005 --- /dev/null +++ b/libs/prisma/prisma/sql/countMapPlayersWithoutShared.sql @@ -0,0 +1,7 @@ +SELECT count(*) AS "count" +FROM "PlayerInfoMap" AS pim +JOIN "Map" AS m ON m."id" = pim."mapId" +JOIN "Player" AS p ON p."name" = pim."playerName" +WHERE m."name" = $1 + AND m."gameTypeName" = $2 + AND (p."pollCount" < $3 OR p."occurrenceCount"::float8 < p."pollCount" * $4::float8); diff --git a/libs/prisma/prisma/sql/countPlayerPartnersWithoutShared.sql b/libs/prisma/prisma/sql/countPlayerPartnersWithoutShared.sql new file mode 100644 index 00000000..3327f432 --- /dev/null +++ b/libs/prisma/prisma/sql/countPlayerPartnersWithoutShared.sql @@ -0,0 +1,5 @@ +SELECT count(*) AS "count" +FROM "PlayerPartner" AS pp +JOIN "Player" AS p ON p."id" = pp."partnerId" +WHERE pp."playerId" = $1 + AND (p."pollCount" < $2 OR p."occurrenceCount"::float8 < p."pollCount" * $3::float8); diff --git a/libs/prisma/prisma/sql/countPlayersWithoutShared.sql b/libs/prisma/prisma/sql/countPlayersWithoutShared.sql new file mode 100644 index 00000000..788450bf --- /dev/null +++ b/libs/prisma/prisma/sql/countPlayersWithoutShared.sql @@ -0,0 +1,3 @@ +SELECT count(*) AS "count" +FROM "Player" +WHERE "pollCount" < $1 OR "occurrenceCount"::float8 < "pollCount" * $2::float8; diff --git a/libs/prisma/prisma/sql/incrementPlayerPollCounts.sql b/libs/prisma/prisma/sql/incrementPlayerPollCounts.sql new file mode 100644 index 00000000..5a9490a6 --- /dev/null +++ b/libs/prisma/prisma/sql/incrementPlayerPollCounts.sql @@ -0,0 +1,6 @@ +UPDATE "Player" AS p +SET + "pollCount" = p."pollCount" + t."pollCount", + "occurrenceCount" = p."occurrenceCount" + t."occurrenceCount" +FROM unnest($1::integer[], $2::integer[], $3::integer[]) AS t("playerId", "pollCount", "occurrenceCount") +WHERE p."id" = t."playerId"; diff --git a/libs/prisma/prisma/sql/listClanPlayersWithoutShared.sql b/libs/prisma/prisma/sql/listClanPlayersWithoutShared.sql new file mode 100644 index 00000000..c3753183 --- /dev/null +++ b/libs/prisma/prisma/sql/listClanPlayersWithoutShared.sql @@ -0,0 +1,8 @@ +SELECT cpi."playerName" +FROM "ClanPlayerInfo" AS cpi +JOIN "Player" AS p ON p."name" = cpi."playerName" +WHERE cpi."clanName" = $1 + AND ($2::bool OR p."clanName" = $1) + AND (p."pollCount" < $3 OR p."occurrenceCount"::float8 < p."pollCount" * $4::float8) +ORDER BY cpi."playTime" DESC +LIMIT $5 OFFSET $6; diff --git a/libs/prisma/prisma/sql/listGameTypePlayersWithoutShared.sql b/libs/prisma/prisma/sql/listGameTypePlayersWithoutShared.sql new file mode 100644 index 00000000..9adc02ba --- /dev/null +++ b/libs/prisma/prisma/sql/listGameTypePlayersWithoutShared.sql @@ -0,0 +1,7 @@ +SELECT pig."playerName" +FROM "PlayerInfoGameType" AS pig +JOIN "Player" AS p ON p."name" = pig."playerName" +WHERE pig."gameTypeName" = $1 + AND (p."pollCount" < $2 OR p."occurrenceCount"::float8 < p."pollCount" * $3::float8) +ORDER BY pig."rating" DESC NULLS LAST, pig."playTime" DESC +LIMIT $4 OFFSET $5; diff --git a/libs/prisma/prisma/sql/listMapPlayersWithoutShared.sql b/libs/prisma/prisma/sql/listMapPlayersWithoutShared.sql new file mode 100644 index 00000000..5ded52da --- /dev/null +++ b/libs/prisma/prisma/sql/listMapPlayersWithoutShared.sql @@ -0,0 +1,9 @@ +SELECT pim."playerName" +FROM "PlayerInfoMap" AS pim +JOIN "Map" AS m ON m."id" = pim."mapId" +JOIN "Player" AS p ON p."name" = pim."playerName" +WHERE m."name" = $1 + AND m."gameTypeName" = $2 + AND (p."pollCount" < $3 OR p."occurrenceCount"::float8 < p."pollCount" * $4::float8) +ORDER BY pim."rating" DESC NULLS LAST, pim."playTime" DESC +LIMIT $5 OFFSET $6; diff --git a/libs/prisma/prisma/sql/listPlayerPartnersWithoutShared.sql b/libs/prisma/prisma/sql/listPlayerPartnersWithoutShared.sql new file mode 100644 index 00000000..7483b301 --- /dev/null +++ b/libs/prisma/prisma/sql/listPlayerPartnersWithoutShared.sql @@ -0,0 +1,7 @@ +SELECT pp."partnerId", pp."playTime", pp."lastPlayedAt" +FROM "PlayerPartner" AS pp +JOIN "Player" AS p ON p."id" = pp."partnerId" +WHERE pp."playerId" = $1 + AND (p."pollCount" < $2 OR p."occurrenceCount"::float8 < p."pollCount" * $3::float8) +ORDER BY pp."playTime" DESC, pp."partnerId" ASC +LIMIT $4 OFFSET $5; diff --git a/libs/prisma/prisma/sql/listPlayersWithoutShared.sql b/libs/prisma/prisma/sql/listPlayersWithoutShared.sql new file mode 100644 index 00000000..6b200338 --- /dev/null +++ b/libs/prisma/prisma/sql/listPlayersWithoutShared.sql @@ -0,0 +1,5 @@ +SELECT "name" +FROM "Player" +WHERE "pollCount" < $1 OR "occurrenceCount"::float8 < "pollCount" * $2::float8 +ORDER BY "playTime" DESC +LIMIT $3 OFFSET $4; diff --git a/libs/prisma/prisma/sql/searchPlayers.sql b/libs/prisma/prisma/sql/searchPlayers.sql index ebf17e2f..b3035666 100644 --- a/libs/prisma/prisma/sql/searchPlayers.sql +++ b/libs/prisma/prisma/sql/searchPlayers.sql @@ -3,6 +3,8 @@ SELECT "public"."Player"."lastSeenAt", "public"."Player"."clanName", "public"."Player"."playTime", + "public"."Player"."pollCount", + "public"."Player"."occurrenceCount", array_agg( DISTINCT jsonb_build_object( 'ip', "public"."GameServer"."ip", @@ -17,6 +19,11 @@ FROM LEFT JOIN "public"."GameServer" ON "public"."GameServer"."id" = "public"."GameServerState"."gameServerId" WHERE "public"."Player"."name" ILIKE $1 + AND ( + $2::bool + OR "public"."Player"."pollCount" < $3 + OR "public"."Player"."occurrenceCount"::float8 < "public"."Player"."pollCount" * $4::float8 + ) GROUP BY "public"."Player"."name", "public"."Player"."lastSeenAt", diff --git a/libs/prisma/prisma/sql/upsertPlayerPartners.sql b/libs/prisma/prisma/sql/upsertPlayerPartners.sql new file mode 100644 index 00000000..8fcd184d --- /dev/null +++ b/libs/prisma/prisma/sql/upsertPlayerPartners.sql @@ -0,0 +1,6 @@ +INSERT INTO "PlayerPartner" ("playerId", "partnerId", "playTime", "lastPlayedAt") +SELECT t."playerId", t."partnerId", t."playTime", $4::date +FROM unnest($1::integer[], $2::integer[], $3::integer[]) AS t("playerId", "partnerId", "playTime") +ON CONFLICT ("playerId", "partnerId") DO UPDATE SET + "playTime" = "PlayerPartner"."playTime" + EXCLUDED."playTime", + "lastPlayedAt" = GREATEST("PlayerPartner"."lastPlayedAt", EXCLUDED."lastPlayedAt"); diff --git a/libs/teerank/src/index.ts b/libs/teerank/src/index.ts index 1233235d..60464469 100644 --- a/libs/teerank/src/index.ts +++ b/libs/teerank/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/bullmq'; export * from './lib/redisConfig'; export * from './lib/storage'; export * from './lib/globalCounts'; +export * from './lib/stubNames'; diff --git a/libs/teerank/src/lib/stubNames.ts b/libs/teerank/src/lib/stubNames.ts new file mode 100644 index 00000000..1a886cf7 --- /dev/null +++ b/libs/teerank/src/lib/stubNames.ts @@ -0,0 +1,11 @@ +import { getEnvFloat, getEnvInt } from "./utils"; + +export const STUB_OCCURRENCE_RATIO = getEnvFloat('STUB_OCCURRENCE_RATIO', 1.5); +export const STUB_MIN_POLL_COUNT = getEnvInt('STUB_MIN_POLL_COUNT', 10); + +export function isStubName(pollCount: number, occurrenceCount: number) { + return ( + pollCount >= STUB_MIN_POLL_COUNT && + occurrenceCount >= pollCount * STUB_OCCURRENCE_RATIO + ); +} diff --git a/libs/teerank/src/lib/utils.ts b/libs/teerank/src/lib/utils.ts index 78cda181..1382bbbf 100644 --- a/libs/teerank/src/lib/utils.ts +++ b/libs/teerank/src/lib/utils.ts @@ -20,3 +20,8 @@ export function getEnvInt(key: string, defaultValue: number) { const value = getEnv(key, defaultValue.toString()); return Number(value); } + +export function getEnvFloat(key: string, defaultValue: number) { + const value = getEnv(key, defaultValue.toString()); + return Number(value); +}