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.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 (
+
+ );
+}
+
+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);
+}