From 035c0c2d94bd24d45ce7d7035b5be9caf0f54415 Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:21:02 +0200 Subject: [PATCH 1/2] Add teammates, shared-name detection, and a reworked player page The daily rollup now tracks time pairs of players spend on the same server (rolling PlayerPartner totals above a tunable daily threshold) and counts per-player polls and server occurrences, all derived from the archived snapshots. A name whose occurrences-per-poll ratio crosses a threshold is a shared name: it gets a corner-bracket ratio badge next to its name, is excluded from teammate tracking, and every player listing can hide such names with an eye toggle whose filtering and pagination run in SQL. The player page is restructured into Playtime (default, game types with a toggleable map column), Activity (recent servers from retained snapshots), Clans, and Teammates tabs; the old /maps route redirects into Playtime. Online links point at the single server or the activity tab when a name is on several servers at once. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + apps/frontend/app/all/page.tsx | 42 +++- .../app/clan/[clanName]/PastMembersToggle.tsx | 32 --- apps/frontend/app/clan/[clanName]/page.tsx | 59 ++++- .../gametype/[gameTypeName]/(lists)/page.tsx | 45 +++- .../[gameTypeName]/map/[mapName]/page.tsx | 47 +++- .../app/player/[playerName]/LayoutTabs.tsx | 25 ++- .../app/player/[playerName]/activity/page.tsx | 205 ++++++++++++++++++ .../app/player/[playerName]/layout.tsx | 29 +-- .../app/player/[playerName]/maps/page.tsx | 62 +----- .../frontend/app/player/[playerName]/page.tsx | 110 +++++++++- .../app/player/[playerName]/playtime/page.tsx | 17 ++ .../player/[playerName]/teammates/page.tsx | 61 ++++++ apps/frontend/app/search/page.tsx | 7 +- apps/frontend/components/EyeToggle.tsx | 73 +++++++ apps/frontend/components/HeaderTabs.tsx | 3 +- apps/frontend/components/LastSeen.tsx | 65 +++--- apps/frontend/components/List.tsx | 6 +- apps/frontend/components/PlayerList.tsx | 73 ++++++- apps/frontend/components/SharedRatio.tsx | 34 +++ apps/frontend/components/Tabs.tsx | 10 +- apps/frontend/components/TeammateList.tsx | 85 ++++++++ apps/frontend/utils/search.ts | 7 +- apps/frontend/utils/shared.ts | 5 + apps/frontend/utils/teammates.ts | 87 ++++++++ apps/worker/src/rollup/aggregateDay.ts | 61 +++++- apps/worker/src/rollup/writeDayRollup.ts | 91 ++++++-- .../workers/fillClanActivePlayerCount.test.ts | 6 + apps/worker/src/workers/rollupDay.test.ts | 131 ++++++++++- .../src/workers/updateGlobalCounts.test.ts | 2 + .../migration.sql | 13 ++ .../migration.sql | 3 + libs/prisma/prisma/schema.prisma | 13 ++ .../sql/countClanPlayersWithoutShared.sql | 6 + .../sql/countGameTypePlayersWithoutShared.sql | 5 + .../sql/countMapPlayersWithoutShared.sql | 7 + .../sql/countPlayerPartnersWithoutShared.sql | 5 + .../prisma/sql/countPlayersWithoutShared.sql | 3 + .../prisma/sql/incrementPlayerPollCounts.sql | 6 + .../sql/listClanPlayersWithoutShared.sql | 8 + .../sql/listGameTypePlayersWithoutShared.sql | 7 + .../sql/listMapPlayersWithoutShared.sql | 9 + .../sql/listPlayerPartnersWithoutShared.sql | 7 + .../prisma/sql/listPlayersWithoutShared.sql | 5 + libs/prisma/prisma/sql/searchPlayers.sql | 7 + .../prisma/sql/upsertPlayerPartners.sql | 6 + libs/teerank/src/index.ts | 1 + libs/teerank/src/lib/stubNames.ts | 11 + libs/teerank/src/lib/utils.ts | 5 + 49 files changed, 1395 insertions(+), 215 deletions(-) delete mode 100644 apps/frontend/app/clan/[clanName]/PastMembersToggle.tsx create mode 100644 apps/frontend/app/player/[playerName]/activity/page.tsx create mode 100644 apps/frontend/app/player/[playerName]/playtime/page.tsx create mode 100644 apps/frontend/app/player/[playerName]/teammates/page.tsx create mode 100644 apps/frontend/components/EyeToggle.tsx create mode 100644 apps/frontend/components/SharedRatio.tsx create mode 100644 apps/frontend/components/TeammateList.tsx create mode 100644 apps/frontend/utils/shared.ts create mode 100644 apps/frontend/utils/teammates.ts create mode 100644 libs/prisma/prisma/migrations/20260825000000_teammates_and_stub_names/migration.sql create mode 100644 libs/prisma/prisma/migrations/20260825000001_game_server_client_player_name_index/migration.sql create mode 100644 libs/prisma/prisma/sql/countClanPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/countGameTypePlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/countMapPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/countPlayerPartnersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/countPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/incrementPlayerPollCounts.sql create mode 100644 libs/prisma/prisma/sql/listClanPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/listGameTypePlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/listMapPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/listPlayerPartnersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/listPlayersWithoutShared.sql create mode 100644 libs/prisma/prisma/sql/upsertPlayerPartners.sql create mode 100644 libs/teerank/src/lib/stubNames.ts 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..93253179 100644 --- a/apps/frontend/app/clan/[clanName]/page.tsx +++ b/apps/frontend/app/clan/[clanName]/page.tsx @@ -7,7 +7,12 @@ 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 +40,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 +85,8 @@ export default async function Index({ name: true, clanName: true, lastSeenAt: true, + pollCount: true, + occurrenceCount: true, gameServerStateClients: { select: { gameServerState: { @@ -69,16 +105,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 +127,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 +150,11 @@ export default async function Index({ return (
- ({ rank: index + 1, name: playerInfo.player.name, @@ -123,6 +162,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); +} From 0fed197c0589a2b2064e48fd3f745aef5806eb16 Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Tue, 25 Aug 2026 00:21:51 +0200 Subject: [PATCH 2/2] Drop unused Link import from the clan page Co-Authored-By: Claude Fable 5 --- apps/frontend/app/clan/[clanName]/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/frontend/app/clan/[clanName]/page.tsx b/apps/frontend/app/clan/[clanName]/page.tsx index 93253179..a2ff795b 100644 --- a/apps/frontend/app/clan/[clanName]/page.tsx +++ b/apps/frontend/app/clan/[clanName]/page.tsx @@ -6,7 +6,6 @@ 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 { STUB_MIN_POLL_COUNT, STUB_OCCURRENCE_RATIO } from '@teerank/teerank'; import { countClanPlayersWithoutShared,