PUBG drop map: live squad map with WebSocket rooms
All checks were successful
Deploy production / deploy (push) Successful in 1m15s

- server.js wraps Bun.serve to add /ws/<channel> upgrades (same server and port) and
  serve the map tiles from the host mount; Dockerfile now runs bun ./server.js
- ws hub (channel registry, origin check) + pubg-map channel: rooms by unguessable
  code, validated + rate-limited messages, per-room write queue, presence
- games.pubg_rooms / pubg_matches / pubg_markers; per-match sessions with history
- /trackers/games/pubg/map hub (owner: rooms, landings over time, history) and
  /trackers/games/pubg/map/<code> fullscreen Leaflet map (all 11 maps)
- deploy-remote.sh bind-mounts ~/pubg-maps; scripts/build-pubg-tiles.py rebuilds it
This commit is contained in:
2026-10-25 22:46:24 +00:00
parent d93374bca2
commit 0fe0d36f87
26 changed files with 39201 additions and 3 deletions

View File

@@ -24,8 +24,11 @@ COPY --from=builder /app/src/lib/availability.ts ./src/lib/availability.ts
COPY --from=builder /app/src/lib/music ./src/lib/music COPY --from=builder /app/src/lib/music ./src/lib/music
# scripts/games-ingest.ts (match history backfills) imports the games ingest. # scripts/games-ingest.ts (match history backfills) imports the games ingest.
COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest COPY --from=builder /app/src/lib/games-ingest ./src/lib/games-ingest
COPY --from=builder /app/server.js ./server.js
COPY package.json ./ COPY package.json ./
EXPOSE 3000 EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV BODY_SIZE_LIMIT=6M ENV BODY_SIZE_LIMIT=6M
CMD ["bun", "./build"] # server.js wraps the adapter-bun build: same server, plus /ws/<channel> WebSockets and the
# PUBG map tiles (/pubg-tiles from the PUBG_TILES_ROOT bind mount, see scripts/deploy-remote.sh).
CMD ["bun", "./server.js"]

View File

@@ -15,6 +15,7 @@
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10", "jose": "^6.2.10",
"leaflet": "^1.9.4",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
@@ -39,6 +40,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1", "@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/leaflet": "^1.9.22",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7", "@typescript/native": "npm:typescript@7",
@@ -473,6 +475,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/leaflet": ["@types/leaflet@1.9.22", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="],
@@ -771,6 +775,8 @@
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="], "kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],

View File

@@ -0,0 +1,37 @@
CREATE TABLE "games"."pubg_markers" (
"id" text PRIMARY KEY,
"match_id" integer NOT NULL,
"kind" text NOT NULL,
"x" double precision NOT NULL,
"y" double precision NOT NULL,
"x2" double precision,
"y2" double precision,
"label" text,
"player" text NOT NULL,
"color" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "games"."pubg_matches" (
"id" serial PRIMARY KEY,
"room_id" integer NOT NULL,
"number" integer NOT NULL,
"map" text NOT NULL,
"started_at" timestamp with time zone DEFAULT now() NOT NULL,
"ended_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "games"."pubg_rooms" (
"id" serial PRIMARY KEY,
"code" text NOT NULL UNIQUE,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "pubg_markers_match_idx" ON "games"."pubg_markers" ("match_id");--> statement-breakpoint
CREATE UNIQUE INDEX "pubg_matches_room_number_idx" ON "games"."pubg_matches" ("room_id","number");--> statement-breakpoint
CREATE INDEX "pubg_matches_map_idx" ON "games"."pubg_matches" ("map","started_at");--> statement-breakpoint
ALTER TABLE "games"."pubg_markers" ADD CONSTRAINT "pubg_markers_match_id_pubg_matches_id_fkey" FOREIGN KEY ("match_id") REFERENCES "games"."pubg_matches"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "games"."pubg_matches" ADD CONSTRAINT "pubg_matches_room_id_pubg_rooms_id_fkey" FOREIGN KEY ("room_id") REFERENCES "games"."pubg_rooms"("id") ON DELETE CASCADE;

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,7 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/d3-geo": "^3.1.1", "@types/d3-geo": "^3.1.1",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/leaflet": "^1.9.22",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
"@types/topojson-client": "^3.1.5", "@types/topojson-client": "^3.1.5",
"@typescript/native": "npm:typescript@7", "@typescript/native": "npm:typescript@7",
@@ -109,6 +110,7 @@
"echarts": "^6.1.0", "echarts": "^6.1.0",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
"jose": "^6.2.10", "jose": "^6.2.10",
"leaflet": "^1.9.4",
"marked": "^18.0.11", "marked": "^18.0.11",
"marked-highlight": "^2.2.4", "marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Build the PUBG drop map tiles from PUBG's official api-assets (github.com/pubg/api-assets,
used under the PUBG Developer API terms; © KRAFTON). Not in the repo or image (~115 MB):
the output goes to the host directory that scripts/deploy-remote.sh bind-mounts at /pubg-maps.
python3 scripts/build-pubg-tiles.py [out_dir] (default ~/pubg-maps; needs Pillow)
Each 8192 px map becomes 512 px WebP tiles, zoom 0 (whole map) to 4 (full resolution),
<out>/<map>/<z>/<x>/<y>.webp, plus a 384 px <out>/<map>/thumb.webp."""
import os, sys, tempfile, urllib.request
from multiprocessing import Pool
from PIL import Image
Image.MAX_IMAGE_PIXELS = None
T = 512
BASE = 'https://media.githubusercontent.com/media/pubg/api-assets/master/Assets/Maps'
# Keep in sync with MAPS in src/lib/pubg-map/shared.ts.
MAPS = {'erangel': 'Erangel', 'miramar': 'Miramar', 'sanhok': 'Sanhok', 'vikendi': 'Vikendi',
'taego': 'Taego', 'deston': 'Deston', 'rondo': 'Rondo', 'karakin': 'Karakin',
'paramo': 'Paramo', 'haven': 'Haven', 'camp-jackal': 'Camp_Jackal'}
OUT = os.path.expanduser(sys.argv[1] if len(sys.argv) > 1 else '~/pubg-maps')
def run(slug):
with tempfile.NamedTemporaryFile(suffix='.png') as f:
urllib.request.urlretrieve(f'{BASE}/{MAPS[slug]}_Main_High_Res.png', f.name)
src = Image.open(f.name).convert('RGB')
for z in range(4, -1, -1):
size = T * 2**z
im = src if size == src.width else src.resize((size, size), Image.LANCZOS)
for x in range(2**z):
d = f'{OUT}/{slug}/{z}/{x}'
os.makedirs(d, exist_ok=True)
for y in range(2**z):
im.crop((x*T, y*T, (x+1)*T, (y+1)*T)).save(f'{d}/{y}.webp', 'WEBP', quality=78, method=5)
src.resize((384, 384), Image.LANCZOS).save(f'{OUT}/{slug}/thumb.webp', 'WEBP', quality=75)
return slug
if __name__ == '__main__':
with Pool(4) as p:
for s in p.imap_unordered(run, list(MAPS)):
print(s, flush=True)

View File

@@ -83,6 +83,31 @@ YAML
else else
echo "No $money_documents_host_dir/documents.json on this host; money documents will not be readable" >&2 echo "No $money_documents_host_dir/documents.json on this host; money documents will not be readable" >&2
fi fi
# PUBG drop map tiles (512 px WebP, from PUBG's official api-assets) also live on the host,
# outside the repo (~120 MB), bind-mounted read-only at /pubg-maps; server.js serves them at
# /pubg-tiles/<map>/<z>/<x>/<y>.webp. Layout: <map>/<z>/<x>/<y>.webp + <map>/thumb.webp.
pubg_tiles_host_dir=${PUBG_TILES_HOST_DIR:-/home/stan/pubg-maps}
case "$pubg_tiles_host_dir" in /*) ;; *) echo 'PUBG_TILES_HOST_DIR must be absolute' >&2; exit 2 ;; esac
if sudo docker run --rm --entrypoint sh \
--mount "type=bind,source=$pubg_tiles_host_dir,target=/pubg-maps,readonly" "$image" \
-c 'test -f /pubg-maps/erangel/thumb.webp' 2>/dev/null; then
pubg_tiles_override="$HOME/compose.pubg-tiles.yml"
rm -f "$pubg_tiles_override"
cat >"$pubg_tiles_override" <<YAML
services:
app:
environment:
PUBG_TILES_ROOT: /pubg-maps
volumes:
- type: bind
source: $pubg_tiles_host_dir
target: /pubg-maps
read_only: true
YAML
compose_files+=(-f "$pubg_tiles_override")
else
echo "No $pubg_tiles_host_dir/erangel on this host; the PUBG map will have no tiles" >&2
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0 switched=0
rollback() { rollback() {

74
server.js Normal file
View File

@@ -0,0 +1,74 @@
/* global Bun */
// Production entry point: `bun ./server.js` instead of `bun ./build`. The adapter-bun build
// starts Bun.serve itself without WebSocket handlers, so this wraps Bun.serve before loading
// it and adds, on the same server and port:
// /ws/<channel> WebSocket upgrades, handled by #lib/server/ws/hub (registered in
// hooks.server.ts init, published on globalThis.__siteWs)
// /pubg-tiles/… the PUBG map tiles, straight from the bind-mounted PUBG_TILES_ROOT
// (immutable, so Cloudflare and browsers cache them; no session lookup)
// Everything else goes to SvelteKit unchanged.
import { resolve, sep } from 'node:path';
const TILE_RE = /^\/pubg-tiles\/([a-z-]{3,20})\/(?:(\d)\/(\d{1,2})\/(\d{1,2})|thumb)\.webp$/;
const tilesRoot = resolve(process.env.PUBG_TILES_ROOT || '/pubg-maps');
function clientIp(request, server) {
const h = request.headers;
return (
h.get('cf-connecting-ip') ||
h.get('x-forwarded-for')?.split(',')[0]?.trim() ||
server.requestIP(request)?.address ||
'unknown'
);
}
async function tile(pathname) {
const m = TILE_RE.exec(pathname);
if (!m) return new Response('Not Found', { status: 404 });
const rel = m[2] === undefined ? `${m[1]}/thumb.webp` : `${m[1]}/${m[2]}/${m[3]}/${m[4]}.webp`;
const path = resolve(tilesRoot, rel);
if (!path.startsWith(tilesRoot + sep)) return new Response('Not Found', { status: 404 });
const file = Bun.file(path);
if (!(await file.exists())) return new Response('Not Found', { status: 404 });
return new Response(file, {
headers: {
'content-type': 'image/webp',
'cache-control': 'public, max-age=31536000, immutable',
'access-control-allow-origin': '*'
}
});
}
const serve = Bun.serve.bind(Bun);
Bun.serve = (options) => {
const app = options.fetch;
options.fetch = async (request, server) => {
const { pathname } = new URL(request.url);
if (pathname.startsWith('/pubg-tiles/')) return tile(pathname);
if (pathname.startsWith('/ws/')) {
const hub = globalThis.__siteWs;
if (!hub) return new Response('Service Unavailable', { status: 503 });
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket')
return new Response('Upgrade Required', { status: 426 });
const data = await hub.authorize(request, new URL(request.url), clientIp(request, server));
if (data instanceof Response) return data;
if (server.upgrade(request, { data })) return undefined;
return new Response('Upgrade Required', { status: 426 });
}
return app(request, server);
};
options.websocket = {
// Messages are small JSON; Bun pings idle sockets so proxies keep them open.
maxPayloadLength: 16 * 1024,
idleTimeout: 60,
sendPings: true,
backpressureLimit: 1024 * 1024,
closeOnBackpressureLimit: true,
open: (ws) => globalThis.__siteWs?.open(ws),
message: (ws, message) => globalThis.__siteWs?.message(ws, message),
close: (ws) => globalThis.__siteWs?.close(ws)
};
return serve(options);
};
await import('./build/index.js');

View File

@@ -1,6 +1,7 @@
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks'; import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit/hooks';
import { startMusicPoller } from '#lib/server/music/poller.js'; import { startMusicPoller } from '#lib/server/music/poller.js';
import { startGamesPoller } from '#lib/server/games/poller.js'; import { startGamesPoller } from '#lib/server/games/poller.js';
import { registerPubgMapChannel } from '#lib/server/pubg-map/live.js';
import { building } from '$app/env'; import { building } from '$app/env';
import { auth } from '#lib/server/auth.js'; import { auth } from '#lib/server/auth.js';
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js'; import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
@@ -9,11 +10,13 @@ import { error } from '@sveltejs/kit';
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/; const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
// Background jobs that live in the app process: the Spotify scrobbler (idle until connected) // Background jobs that live in the app process: the Spotify scrobbler (idle until connected)
// and the CS2 / PUBG / R6 match history poller. // and the CS2 / PUBG / R6 match history poller. Also the WebSocket channels (served by
// server.js on the same Bun server): the PUBG drop map rooms.
export const init: ServerInit = () => { export const init: ServerInit = () => {
if (!building) { if (!building) {
startMusicPoller(); startMusicPoller();
startGamesPoller(); startGamesPoller();
registerPubgMapChannel();
} }
}; };

View File

@@ -4,7 +4,11 @@
* only imports SiteChrome for other pages. * only imports SiteChrome for other pages.
*/ */
export function isImmersive(pathname: string): boolean { export function isImmersive(pathname: string): boolean {
return /^\/trackers\/books\/\d+\/read\/?$/.test(pathname); return (
/^\/trackers\/books\/\d+\/read\/?$/.test(pathname) ||
// A PUBG drop map room (full-screen, for a second monitor).
/^\/trackers\/games\/pubg\/map\/[A-Za-z0-9]{20}\/?$/.test(pathname)
);
} }
/** Pages that show the site header but no footer: the /start new-tab dashboard. */ /** Pages that show the site header but no footer: the /start new-tab dashboard. */

File diff suppressed because it is too large Load Diff

38
src/lib/pubg-map/pins.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { Kind } from './shared';
// Map pins are Leaflet divIcons (plain HTML), so their icons are inline SVG: the Lucide
// paths for plane, flame, car, star and sticky-note (ISC licence, like lucide-svelte).
const svg = (body: string) =>
`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
export const PIN_SVG: Record<Exclude<Kind, 'landing'>, string> = {
plane: svg(
'<path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/>'
),
hotspot: svg(
'<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4"/>'
),
vehicle: svg(
'<path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/>'
),
poi: svg(
'<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>'
),
note: svg(
'<path d="M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z"/><path d="M15 3v5a1 1 0 0 0 1 1h5"/>'
)
};
export const esc = (s: string) =>
s.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!
);
export const initials = (name: string) =>
name
.split(/\s+/)
.map((w) => [...w][0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase();

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { cleanText, gridRef, mapInfo, parseClientMsg, ROOM_CODE_RE } from './shared';
import { newRoomCode } from '../server/pubg-map/code';
describe('pubg map protocol', () => {
it('accepts a valid marker and rounds coordinates', () => {
const m = parseClientMsg(
JSON.stringify({
t: 'add',
m: { id: 'abcdefgh-1234', kind: 'hotspot', x: 0.123456789, y: 0.5 }
})
);
expect(m).toEqual({
t: 'add',
m: {
id: 'abcdefgh-1234',
kind: 'hotspot',
x: 0.123457,
y: 0.5,
x2: null,
y2: null,
label: null
}
});
});
it('rejects out-of-range, unknown kinds, bad ids and oversized frames', () => {
const add = (m: object) => parseClientMsg(JSON.stringify({ t: 'add', m }));
expect(add({ id: 'abcdefgh', kind: 'hotspot', x: 1.2, y: 0 })).toBeNull();
expect(add({ id: 'abcdefgh', kind: 'nuke', x: 0, y: 0 })).toBeNull();
expect(add({ id: '<script>', kind: 'poi', x: 0, y: 0 })).toBeNull();
expect(add({ id: 'abcdefgh', kind: 'plane', x: 0, y: 0 })).toBeNull();
expect(parseClientMsg('x'.repeat(3000))).toBeNull();
expect(parseClientMsg('not json')).toBeNull();
expect(parseClientMsg(JSON.stringify({ t: 'new-match', map: 'atlantis' }))).toBeNull();
expect(parseClientMsg(JSON.stringify({ t: 'hello', name: ' ', color: '#ffffff' }))).toBeNull();
});
it('cleans names and notes', () => {
expect(cleanText(' a\u0000b\u202e ', 10)).toBe('a b');
expect(cleanText('x'.repeat(100), 24)).toHaveLength(24);
});
it('makes grid refs like the game', () => {
expect(gridRef(mapInfo('erangel'), 0, 0)).toBe('A1');
expect(gridRef(mapInfo('erangel'), 0.99, 0.99)).toBe('H8');
expect(gridRef(mapInfo('sanhok'), 0.6, 0.3)).toBe('C2');
});
it('makes unguessable room codes', () => {
const codes = new Set(Array.from({ length: 200 }, newRoomCode));
expect(codes.size).toBe(200);
for (const c of codes) expect(ROOM_CODE_RE.test(c)).toBe(true);
});
});

220
src/lib/pubg-map/shared.ts Normal file
View File

@@ -0,0 +1,220 @@
// Shared by the PUBG drop map's server (WebSocket channel, loads) and its browser client:
// the maps, the marker kinds and the wire protocol, plus the validation of every message.
export type MapInfo = {
slug: string;
name: string;
/** Playable size in km (the image always covers the whole square). */
km: number;
/** PUBG API mapName, so API matches can be matched to a map later. */
api: string;
};
/** Every current PUBG map, the four classics first. Tiles: /pubg-tiles/<slug>/<z>/<x>/<y>.webp */
export const MAPS: MapInfo[] = [
{ slug: 'erangel', name: 'Erangel', km: 8, api: 'Baltic_Main' },
{ slug: 'miramar', name: 'Miramar', km: 8, api: 'Desert_Main' },
{ slug: 'sanhok', name: 'Sanhok', km: 4, api: 'Savage_Main' },
{ slug: 'vikendi', name: 'Vikendi', km: 8, api: 'DihorOtok_Main' },
{ slug: 'taego', name: 'Taego', km: 8, api: 'Tiger_Main' },
{ slug: 'deston', name: 'Deston', km: 8, api: 'Kiki_Main' },
{ slug: 'rondo', name: 'Rondo', km: 8, api: 'Neon_Main' },
{ slug: 'karakin', name: 'Karakin', km: 2, api: 'Summerland_Main' },
{ slug: 'paramo', name: 'Paramo', km: 3, api: 'Chimera_Main' },
{ slug: 'haven', name: 'Haven', km: 1, api: 'Heaven_Main' },
{ slug: 'camp-jackal', name: 'Camp Jackal', km: 2, api: 'Range_Main' }
];
export const MAP_SLUGS = new Set(MAPS.map((m) => m.slug));
export const mapInfo = (slug: string) => MAPS.find((m) => m.slug === slug) ?? MAPS[0];
/** Tiles are 512 px; zoom 0 is the whole map in one tile, zoom 4 is the full 8192 px image. */
export const TILE_SIZE = 512;
export const MAX_NATIVE_ZOOM = 4;
export const KINDS = ['landing', 'plane', 'hotspot', 'vehicle', 'poi', 'note'] as const;
export type Kind = (typeof KINDS)[number];
export const KIND_INFO: Record<Kind, { label: string; key: string; color: string; hint: string }> =
{
landing: { label: 'Landing', key: '1', color: '#22c55e', hint: 'where you dropped (one each)' },
plane: { label: 'Plane path', key: '2', color: '#38bdf8', hint: 'two clicks: start, end' },
hotspot: { label: 'Hotspot', key: '3', color: '#f97316', hint: 'fights, hot drops' },
vehicle: { label: 'Vehicle', key: '4', color: '#a78bfa', hint: 'cars, boats, gliders' },
poi: {
label: 'Point of interest',
key: '5',
color: '#facc15',
hint: 'loot, crates, safe spots'
},
note: { label: 'Note', key: '6', color: '#f472b6', hint: 'free marker with a short note' }
};
/** Player colours: bright on the dark map, picked in order of joining. */
export const PLAYER_COLORS = [
'#22c55e',
'#38bdf8',
'#f97316',
'#e879f9',
'#facc15',
'#f43f5e',
'#2dd4bf',
'#a3e635'
];
export type Marker = {
id: string;
kind: Kind;
/** 0..1 across and down the map image. */
x: number;
y: number;
/** Plane path end. */
x2: number | null;
y2: number | null;
label: string | null;
player: string;
color: string;
createdAt: string;
};
export type Match = { id: number; number: number; map: string; startedAt: string };
export type Player = { sid: string; name: string; color: string };
export type PastMatch = Match & {
endedAt: string | null;
landings: { player: string; color: string; x: number; y: number }[];
markers: number;
plane: { x: number; y: number; x2: number; y2: number } | null;
};
export type ClientMsg =
| { t: 'hello'; name: string; color: string }
| { t: 'add'; m: Pick<Marker, 'id' | 'kind' | 'x' | 'y' | 'x2' | 'y2' | 'label'> }
| { t: 'move'; id: string; x: number; y: number; x2: number | null; y2: number | null }
| { t: 'label'; id: string; label: string | null }
| { t: 'del'; id: string }
| { t: 'clear' }
| { t: 'new-match'; map: string }
| { t: 'set-map'; map: string }
| { t: 'flash'; x: number; y: number };
export type ServerMsg =
| {
t: 'init';
you: string;
room: { code: string; name: string };
match: Match;
markers: Marker[];
players: Player[];
}
| { t: 'add'; m: Marker; by: string }
| {
t: 'move';
id: string;
x: number;
y: number;
x2: number | null;
y2: number | null;
by: string;
}
| { t: 'label'; id: string; label: string | null; by: string }
| { t: 'del'; ids: string[]; by: string }
| { t: 'match'; match: Match; markers: Marker[]; by: string }
| { t: 'players'; players: Player[] }
| { t: 'flash'; x: number; y: number; color: string; name: string }
| { t: 'err'; msg: string };
export const MAX_NAME = 24;
export const MAX_LABEL = 80;
export const MAX_MARKERS = 400;
const ID_RE = /^[A-Za-z0-9_-]{8,40}$/;
const COLOR_RE = /^#[0-9a-f]{6}$/i;
/** Room codes: 20 characters of base62 (about 119 random bits), unguessable. */
export const ROOM_CODE_RE = /^[A-Za-z0-9]{20}$/;
const coord = (v: unknown): v is number =>
typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
const round = (v: number) => Math.round(v * 1e6) / 1e6;
// Names and notes: printable text only, no control characters, trimmed and capped.
export function cleanText(v: unknown, max: number): string | null {
if (typeof v !== 'string') return null;
// eslint-disable-next-line no-control-regex
const s = v.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e]/g, ' ').trim();
return s ? s.slice(0, max) : null;
}
/** Parse and validate one message from a browser; null when it is not acceptable. */
export function parseClientMsg(raw: string): ClientMsg | null {
if (raw.length > 2048) return null;
let d: Record<string, unknown>;
try {
d = JSON.parse(raw);
} catch {
return null;
}
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
switch (d.t) {
case 'hello': {
const name = cleanText(d.name, MAX_NAME);
if (!name || typeof d.color !== 'string' || !COLOR_RE.test(d.color)) return null;
return { t: 'hello', name, color: d.color.toLowerCase() };
}
case 'add': {
const m = d.m as Record<string, unknown> | undefined;
if (!m || typeof m !== 'object') return null;
if (typeof m.id !== 'string' || !ID_RE.test(m.id)) return null;
if (!KINDS.includes(m.kind as Kind)) return null;
if (!coord(m.x) || !coord(m.y)) return null;
const plane = m.kind === 'plane';
if (plane && (!coord(m.x2) || !coord(m.y2))) return null;
return {
t: 'add',
m: {
id: m.id,
kind: m.kind as Kind,
x: round(m.x),
y: round(m.y),
x2: plane ? round(m.x2 as number) : null,
y2: plane ? round(m.y2 as number) : null,
label: cleanText(m.label, MAX_LABEL)
}
};
}
case 'move': {
if (typeof d.id !== 'string' || !ID_RE.test(d.id) || !coord(d.x) || !coord(d.y)) return null;
const both = coord(d.x2) && coord(d.y2);
return {
t: 'move',
id: d.id,
x: round(d.x),
y: round(d.y),
x2: both ? round(d.x2 as number) : null,
y2: both ? round(d.y2 as number) : null
};
}
case 'label':
if (typeof d.id !== 'string' || !ID_RE.test(d.id)) return null;
return { t: 'label', id: d.id, label: cleanText(d.label, MAX_LABEL) };
case 'del':
if (typeof d.id !== 'string' || !ID_RE.test(d.id)) return null;
return { t: 'del', id: d.id };
case 'clear':
return { t: 'clear' };
case 'new-match':
case 'set-map':
if (typeof d.map !== 'string' || !MAP_SLUGS.has(d.map)) return null;
return { t: d.t, map: d.map };
case 'flash':
if (!coord(d.x) || !coord(d.y)) return null;
return { t: 'flash', x: round(d.x), y: round(d.y) };
default:
return null;
}
}
/** Grid callouts like in game: columns A–… and rows 1–… per km (an 8 km map is A–H, 1–8). */
export function gridRef(map: MapInfo, x: number, y: number) {
const n = Math.max(1, Math.round(map.km));
const col = Math.min(n - 1, Math.floor(x * n));
const row = Math.min(n - 1, Math.floor(y * n));
return `${String.fromCharCode(65 + col)}${row + 1}`;
}

View File

@@ -27,3 +27,4 @@ export * from './music';
export * from './hardware'; export * from './hardware';
export * from './dataholders'; export * from './dataholders';
export * from './stats'; export * from './stats';
export * from './pubg-map';

View File

@@ -0,0 +1,62 @@
import { doublePrecision, index, integer, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { gamesSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// The live PUBG drop map (/trackers/games/pubg/map): rooms that friends join with an
// unguessable share code, one match per game played (map, landings, plane path, hotspots,
// vehicles, notes), and the markers themselves. Only the owner creates rooms and deletes
// history; anyone with a room's code edits that room's current match over the site WebSocket.
export const pubgRooms = gamesSchema.table('pubg_rooms', {
id: id(),
/** 20 characters of base62; the share link is /trackers/games/pubg/map/<code>. */
code: text('code').notNull().unique(),
name: text('name').notNull(),
...timestamps
});
export const pubgMatches = gamesSchema.table(
'pubg_matches',
{
id: id(),
roomId: integer('room_id')
.notNull()
.references(() => pubgRooms.id, { onDelete: 'cascade' }),
/** 1, 2, 3… within the room. */
number: integer('number').notNull(),
/** #lib/pubg-map/shared MAPS slug: erangel, miramar, sanhok, vikendi… */
map: text('map').notNull(),
startedAt: timestamptz('started_at').notNull().defaultNow(),
/** Set when "New match" archives it; null for the room's current match. */
endedAt: timestamptz('ended_at')
},
(t) => [
uniqueIndex('pubg_matches_room_number_idx').on(t.roomId, t.number),
index('pubg_matches_map_idx').on(t.map, t.startedAt)
]
);
export const pubgMarkers = gamesSchema.table(
'pubg_markers',
{
/** Made by the browser that placed it, so its own copy and the broadcast share an id. */
id: text('id').primaryKey(),
matchId: integer('match_id')
.notNull()
.references(() => pubgMatches.id, { onDelete: 'cascade' }),
/** landing, plane, hotspot, vehicle, poi, note. */
kind: text('kind').notNull(),
/** 0..1 across and down the map image. */
x: doublePrecision('x').notNull(),
y: doublePrecision('y').notNull(),
/** Plane path end point. */
x2: doublePrecision('x2'),
y2: doublePrecision('y2'),
label: text('label'),
/** Nickname and colour of whoever placed it (friends have no account). */
player: text('player').notNull(),
color: text('color').notNull(),
...timestamps
},
(t) => [index('pubg_markers_match_idx').on(t.matchId)]
);

View File

@@ -0,0 +1,12 @@
// Room codes for the PUBG drop map (the share link's only secret).
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
/** 20 characters of base62, from crypto.getRandomValues. */
export function newRoomCode() {
// 20 × log2(62) ≈ 119 bits; rejection sampling keeps every character equally likely.
let out = '';
while (out.length < 20) {
for (const b of crypto.getRandomValues(new Uint8Array(32)))
if (b < 248 && out.length < 20) out += ALPHABET[b % 62];
}
return out;
}

View File

@@ -0,0 +1,269 @@
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
import {
MAP_SLUGS,
MAX_MARKERS,
ROOM_CODE_RE,
parseClientMsg,
type ClientMsg,
type Match,
type Player,
type ServerMsg
} from '#lib/pubg-map/shared.js';
import * as store from './store';
// Realtime side of the PUBG drop map: the `pubg-map` channel of the site WebSocket hub
// (wss://<host>/ws/pubg-map?room=<code>). Every socket belongs to one room; every change is
// written to Postgres first and then sent to everyone in the room, the sender included.
// Access is the room code itself (unguessable, shared by link); Stan's login is not needed.
type Data = {
channel: string;
ip: string;
roomId: number;
code: string;
name: string;
sid: string;
player: Player | null;
tokens: number;
last: number;
strikes: number;
lastFlash: number;
};
type WS = Socket<Data>;
type Room = {
id: number;
code: string;
name: string;
sockets: Set<WS>;
match: Match | null;
/** Serialises writes per room, so e.g. two "New match" clicks make one new match. */
queue: Promise<unknown>;
};
const MAX_PER_ROOM = 32;
const MAX_PER_IP = 12;
// Token bucket per socket: bursts of 30 messages, 10 a second sustained.
const BURST = 30;
const RATE = 10;
const rooms = new Map<number, Room>();
const perIp = new Map<string, number>();
const send = (ws: WS, msg: ServerMsg) => ws.send(JSON.stringify(msg));
function broadcast(room: Room, msg: ServerMsg) {
const text = JSON.stringify(msg);
for (const ws of room.sockets) ws.send(text);
}
const playersOf = (room: Room) =>
[...room.sockets].flatMap((ws) => (ws.data.player ? [ws.data.player] : []));
function sid() {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
}
function enqueue(room: Room, job: () => Promise<void>) {
room.queue = room.queue.then(job).catch((e) => console.error('[ws pubg-map]', e));
return room.queue;
}
async function ensureMatch(room: Room) {
room.match ??= await store.currentMatch(room.id);
return room.match;
}
function allow(ws: WS) {
const now = Date.now();
const d = ws.data;
d.tokens = Math.min(BURST, d.tokens + ((now - d.last) / 1000) * RATE);
d.last = now;
if (d.tokens >= 1) {
d.tokens -= 1;
return true;
}
if (++d.strikes > 50) ws.close(1008, 'rate limit');
else send(ws, { t: 'err', msg: 'Slow down a little.' });
return false;
}
async function handle(room: Room, ws: WS, msg: ClientMsg) {
const player = ws.data.player;
if (msg.t === 'hello') {
ws.data.player = { sid: ws.data.sid, name: msg.name, color: msg.color };
broadcast(room, { t: 'players', players: playersOf(room) });
return;
}
if (!player) return send(ws, { t: 'err', msg: 'Pick a nickname first.' });
const by = player.sid;
const match = await ensureMatch(room);
switch (msg.t) {
case 'add': {
const existing = await store.markersOf(match.id);
if (existing.length >= MAX_MARKERS)
return send(ws, { t: 'err', msg: 'This match is full; clear something first.' });
// One landing per player and one plane path per match: placing a new one replaces it.
const replace = existing
.filter((m) =>
msg.m.kind === 'landing'
? m.kind === 'landing' && m.player === player.name
: msg.m.kind === 'plane' && m.kind === 'plane'
)
.map((m) => m.id);
const gone = await store.deleteMarkers(match.id, replace);
if (gone.length) broadcast(room, { t: 'del', ids: gone, by });
const m = await store.insertMarker(match.id, {
...msg.m,
player: player.name,
color: player.color
});
if (m) broadcast(room, { t: 'add', m, by });
return;
}
case 'move':
if (
await store.updateMarker(match.id, msg.id, {
x: msg.x,
y: msg.y,
...(msg.x2 != null ? { x2: msg.x2, y2: msg.y2 } : {})
})
)
broadcast(room, { t: 'move', id: msg.id, x: msg.x, y: msg.y, x2: msg.x2, y2: msg.y2, by });
return;
case 'label':
if (await store.updateMarker(match.id, msg.id, { label: msg.label }))
broadcast(room, { t: 'label', id: msg.id, label: msg.label, by });
return;
case 'del': {
const ids = await store.deleteMarkers(match.id, [msg.id]);
if (ids.length) broadcast(room, { t: 'del', ids, by });
return;
}
case 'clear':
await store.clearMatch(match.id);
broadcast(room, { t: 'match', match, markers: [], by });
return;
case 'set-map':
await store.setMatchMap(match.id, msg.map);
room.match = { ...match, map: msg.map };
broadcast(room, {
t: 'match',
match: room.match,
markers: await store.markersOf(match.id),
by
});
return;
case 'new-match': {
// An untouched match is not worth keeping in the history: just switch its map.
const markers = await store.markersOf(match.id);
if (!markers.length) {
await store.setMatchMap(match.id, msg.map);
room.match = { ...match, map: msg.map, startedAt: new Date().toISOString() };
} else room.match = await store.startMatch(room.id, msg.map, match.id);
broadcast(room, { t: 'match', match: room.match, markers: [], by });
return;
}
}
}
/** Called by the owner's HTTP actions so open maps notice right away. */
export function roomDeleted(roomId: number) {
const room = rooms.get(roomId);
if (!room) return;
for (const ws of room.sockets) ws.close(4404, 'room deleted');
rooms.delete(roomId);
}
let registered = false;
export function registerPubgMapChannel() {
if (registered) return;
registered = true;
registerChannel('pubg-map', {
async authorize(_request, url, ip) {
const code = url.searchParams.get('room') ?? '';
if (!ROOM_CODE_RE.test(code)) return new Response('Not Found', { status: 404 });
if ((perIp.get(ip) ?? 0) >= MAX_PER_IP)
return new Response('Too Many Requests', { status: 429 });
const row = await store.roomByCode(code);
if (!row) return new Response('Not Found', { status: 404 });
if ((rooms.get(row.id)?.sockets.size ?? 0) >= MAX_PER_ROOM)
return new Response('Room full', { status: 429 });
return {
roomId: row.id,
code: row.code,
name: row.name,
sid: sid(),
player: null,
tokens: BURST,
last: Date.now(),
strikes: 0,
lastFlash: 0
};
},
open(raw) {
const ws = raw as unknown as WS;
const d = ws.data;
perIp.set(d.ip, (perIp.get(d.ip) ?? 0) + 1);
let room = rooms.get(d.roomId);
if (!room) {
room = {
id: d.roomId,
code: d.code,
name: d.name,
sockets: new Set(),
match: null,
queue: Promise.resolve()
};
rooms.set(d.roomId, room);
}
room.sockets.add(ws);
const r = room;
enqueue(r, async () => {
const match = await ensureMatch(r);
send(ws, {
t: 'init',
you: d.sid,
room: { code: r.code, name: r.name },
match,
markers: await store.markersOf(match.id),
players: playersOf(r)
});
});
},
message(raw, text) {
const ws = raw as unknown as WS;
const room = rooms.get(ws.data.roomId);
if (!room || !allow(ws)) return;
const msg = parseClientMsg(text);
if (!msg) return send(ws, { t: 'err', msg: 'Bad message.' });
if (msg.t === 'flash') {
// Pings are not stored; one a second per player is plenty.
const now = Date.now();
if (!ws.data.player || now - ws.data.lastFlash < 1000) return;
ws.data.lastFlash = now;
return broadcast(room, {
t: 'flash',
x: msg.x,
y: msg.y,
color: ws.data.player.color,
name: ws.data.player.name
});
}
if ((msg.t === 'new-match' || msg.t === 'set-map') && !MAP_SLUGS.has(msg.map)) return;
enqueue(room, async () => {
await handle(room, ws, msg);
});
},
close(raw) {
const ws = raw as unknown as WS;
const d = ws.data;
const n = (perIp.get(d.ip) ?? 1) - 1;
if (n > 0) perIp.set(d.ip, n);
else perIp.delete(d.ip);
const room = rooms.get(d.roomId);
if (!room) return;
room.sockets.delete(ws);
if (!room.sockets.size) rooms.delete(d.roomId);
else if (d.player) broadcast(room, { t: 'players', players: playersOf(room) });
}
});
}

View File

@@ -0,0 +1,183 @@
import { and, desc, eq, inArray, isNotNull, sql } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { pubgMarkers, pubgMatches, pubgRooms } from '#lib/server/db/schema/index.js';
import type { Kind, Marker, Match, PastMatch } from '#lib/pubg-map/shared.js';
// Postgres side of the PUBG drop map (games.pubg_rooms, pubg_matches, pubg_markers).
const iso = (d: Date) => d.toISOString();
type MarkerRow = typeof pubgMarkers.$inferSelect;
export const toMarker = (r: MarkerRow): Marker => ({
id: r.id,
kind: r.kind as Kind,
x: r.x,
y: r.y,
x2: r.x2,
y2: r.y2,
label: r.label,
player: r.player,
color: r.color,
createdAt: iso(r.createdAt)
});
const toMatch = (r: typeof pubgMatches.$inferSelect): Match => ({
id: r.id,
number: r.number,
map: r.map,
startedAt: iso(r.startedAt)
});
export { newRoomCode } from './code';
import { newRoomCode } from './code';
export async function roomByCode(code: string) {
const [room] = await db.select().from(pubgRooms).where(eq(pubgRooms.code, code));
return room ?? null;
}
export async function listRooms() {
return db
.select({
id: pubgRooms.id,
code: pubgRooms.code,
name: pubgRooms.name,
createdAt: pubgRooms.createdAt,
matches: sql<number>`(select count(*)::int from games.pubg_matches m where m.room_id = ${pubgRooms.id})`
})
.from(pubgRooms)
.orderBy(desc(pubgRooms.createdAt));
}
export async function createRoom(name: string) {
const [room] = await db.insert(pubgRooms).values({ code: newRoomCode(), name }).returning();
return room;
}
export async function deleteRoom(id: number) {
await db.delete(pubgRooms).where(eq(pubgRooms.id, id));
}
/** The room's open match, created on Erangel when the room has none yet. */
export async function currentMatch(roomId: number): Promise<Match> {
const [open] = await db
.select()
.from(pubgMatches)
.where(and(eq(pubgMatches.roomId, roomId), sql`${pubgMatches.endedAt} is null`))
.orderBy(desc(pubgMatches.number))
.limit(1);
if (open) return toMatch(open);
return startMatch(roomId, 'erangel');
}
export async function startMatch(roomId: number, map: string, endId?: number): Promise<Match> {
return db.transaction(async (tx) => {
if (endId)
await tx.update(pubgMatches).set({ endedAt: new Date() }).where(eq(pubgMatches.id, endId));
const [{ next }] = await tx
.select({ next: sql<number>`coalesce(max(${pubgMatches.number}), 0)::int + 1` })
.from(pubgMatches)
.where(eq(pubgMatches.roomId, roomId));
const [row] = await tx.insert(pubgMatches).values({ roomId, number: next, map }).returning();
return toMatch(row);
});
}
export async function setMatchMap(matchId: number, map: string) {
await db.update(pubgMatches).set({ map }).where(eq(pubgMatches.id, matchId));
}
export async function markersOf(matchId: number) {
const rows = await db
.select()
.from(pubgMarkers)
.where(eq(pubgMarkers.matchId, matchId))
.orderBy(pubgMarkers.createdAt);
return rows.map(toMarker);
}
export async function insertMarker(matchId: number, m: Omit<Marker, 'createdAt'>) {
const [row] = await db
.insert(pubgMarkers)
.values({ ...m, matchId })
.onConflictDoNothing()
.returning();
return row ? toMarker(row) : null;
}
export async function updateMarker(
matchId: number,
id: string,
set: Partial<Pick<MarkerRow, 'x' | 'y' | 'x2' | 'y2' | 'label'>>
) {
const rows = await db
.update(pubgMarkers)
.set(set)
.where(and(eq(pubgMarkers.id, id), eq(pubgMarkers.matchId, matchId)))
.returning({ id: pubgMarkers.id });
return rows.length > 0;
}
export async function deleteMarkers(matchId: number, ids: string[]) {
if (!ids.length) return [];
const rows = await db
.delete(pubgMarkers)
.where(and(eq(pubgMarkers.matchId, matchId), inArray(pubgMarkers.id, ids)))
.returning({ id: pubgMarkers.id });
return rows.map((r) => r.id);
}
export async function clearMatch(matchId: number) {
await db.delete(pubgMarkers).where(eq(pubgMarkers.matchId, matchId));
}
/** Archived matches with their landings and plane path, newest first. */
export async function pastMatches(
opts: { roomId?: number; limit?: number } = {}
): Promise<(PastMatch & { roomId: number })[]> {
const matches = await db
.select()
.from(pubgMatches)
.where(
and(
isNotNull(pubgMatches.endedAt),
opts.roomId ? eq(pubgMatches.roomId, opts.roomId) : undefined
)
)
.orderBy(desc(pubgMatches.startedAt))
.limit(opts.limit ?? 200);
if (!matches.length) return [];
const markers = await db
.select()
.from(pubgMarkers)
.where(
inArray(
pubgMarkers.matchId,
matches.map((m) => m.id)
)
);
return matches.map((m) => {
const mine = markers.filter((r) => r.matchId === m.id);
const plane = mine.find((r) => r.kind === 'plane');
return {
...toMatch(m),
roomId: m.roomId,
endedAt: m.endedAt ? iso(m.endedAt) : null,
markers: mine.length,
landings: mine
.filter((r) => r.kind === 'landing')
.map((r) => ({ player: r.player, color: r.color, x: r.x, y: r.y })),
plane:
plane && plane.x2 != null && plane.y2 != null
? { x: plane.x, y: plane.y, x2: plane.x2, y2: plane.y2 }
: null
};
});
}
/** Owner only (checked by the caller): delete one archived match and its markers. */
export async function deletePastMatch(matchId: number) {
const rows = await db
.delete(pubgMatches)
.where(and(eq(pubgMatches.id, matchId), isNotNull(pubgMatches.endedAt)))
.returning({ roomId: pubgMatches.roomId });
return rows[0]?.roomId ?? null;
}

64
src/lib/server/ws/hub.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { ServerWebSocket } from 'bun';
// The site's WebSocket hub. Bun.serve in production is started by server.js (a thin wrapper
// around the adapter-bun build), which upgrades /ws/<channel> requests on the same server and
// port as the site and hands every socket event to the handlers registered here. A feature
// registers a channel (see #lib/server/pubg-map/live) from hooks.server.ts init(); each channel
// does its own access check in authorize() and keeps its own rooms.
// `vite dev` has no Bun.serve, so channels only work in the built app (bun ./server.js).
export type WsData = { channel: string; ip: string } & Record<string, unknown>;
export type Socket<T = WsData> = ServerWebSocket<T>;
export type Channel = {
/** Decide on an upgrade: per-socket data to keep, or a Response to refuse it. */
authorize(request: Request, url: URL, ip: string): Promise<Record<string, unknown> | Response>;
open(ws: Socket): void;
message(ws: Socket, message: string): void;
close(ws: Socket): void;
};
const channels = new Map<string, Channel>();
/** Same-origin only for browsers (an Origin header must name the host that was asked for). */
function originAllowed(request: Request) {
const origin = request.headers.get('origin');
if (!origin) return true;
try {
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
return new URL(origin).host === host;
} catch {
return false;
}
}
const hub = {
async authorize(request: Request, url: URL, ip: string): Promise<WsData | Response> {
const name = url.pathname.replace(/^\/ws\//, '').replace(/\/$/, '');
const channel = channels.get(name);
if (!channel) return new Response('Not Found', { status: 404 });
if (!originAllowed(request)) return new Response('Forbidden', { status: 403 });
try {
const data = await channel.authorize(request, url, ip);
return data instanceof Response ? data : { ...data, channel: name, ip };
} catch (e) {
console.error(`[ws ${name}] authorize failed`, e);
return new Response('Internal Error', { status: 500 });
}
},
open(ws: Socket) {
channels.get(ws.data.channel)?.open(ws);
},
message(ws: Socket, message: string | Buffer) {
if (typeof message !== 'string') return void ws.close(1003, 'text only');
channels.get(ws.data.channel)?.message(ws, message);
},
close(ws: Socket) {
channels.get(ws.data.channel)?.close(ws);
}
};
export function registerChannel(name: string, channel: Channel) {
channels.set(name, channel);
(globalThis as { __siteWs?: typeof hub }).__siteWs = hub;
}

View File

@@ -8,6 +8,7 @@
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'; import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left'; import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right'; import ArrowRight from 'lucide-svelte/icons/arrow-right';
import MapIcon from 'lucide-svelte/icons/map';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import MatchHistory from '#lib/game-tracker/MatchHistory.svelte'; import MatchHistory from '#lib/game-tracker/MatchHistory.svelte';
import { gameTrackers } from '#lib/projects.js'; import { gameTrackers } from '#lib/projects.js';
@@ -114,6 +115,22 @@
<MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} /> <MatchHistory history={data.history} game={game.slug as 'cs2' | 'pubg' | 'r6'} />
{/if} {/if}
{#if game.slug === 'pubg' && data.owner}
<a
href={resolve('/trackers/games/pubg/map')}
class="deep mb-8 inline-flex items-center gap-3 p-4"
>
<MapIcon size={28} aria-hidden="true" />
<span>
<span class="block font-medium">drop map</span>
<span class="block text-sm text-[var(--site-muted)]"
>live squad map: landings, plane path, hotspots, cars</span
>
</span>
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
</a>
{/if}
{#if game.notes} {#if game.notes}
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p> <p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
{/if} {/if}

View File

@@ -0,0 +1,53 @@
import { fail, redirect } from '@sveltejs/kit';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { cleanText } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import { roomDeleted } from '#lib/server/pubg-map/live.js';
import type { Actions, PageServerLoad } from './$types';
// The PUBG drop map hub. Stan (owner) sees his rooms, makes new ones and manages the match
// history (landings over time). Friends never need this page: they open a room's share link.
export const load: PageServerLoad = async (event) => {
const owner = isOwner(event);
const seo = {
title: 'PUBG drop map · Games',
description: 'A live squad map for PUBG: landings, plane path, hotspots and vehicles.',
index: false
};
if (!owner) return { owner, rooms: [], history: [], seo };
const [rooms, history] = await Promise.all([
store.listRooms(),
store.pastMatches({ limit: 300 })
]);
return {
owner,
rooms: rooms.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
history,
seo
};
};
export const actions: Actions = {
createRoom: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const name = cleanText(form.get('name'), 40) ?? 'Squad';
const room = await store.createRoom(name);
redirect(303, `/trackers/games/pubg/map/${room.code}`);
},
deleteRoom: async (event) => {
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id)) return fail(400, { message: 'Bad room' });
await store.deleteRoom(id);
roomDeleted(id);
return { ok: true };
},
deleteMatch: async (event) => {
requireOwner(event);
const id = Number((await event.request.formData()).get('id'));
if (!Number.isInteger(id)) return fail(400, { message: 'Bad match' });
if (!(await store.deletePastMatch(id))) return fail(404, { message: 'Not an archived match' });
return { ok: true };
}
};

View File

@@ -0,0 +1,295 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Link from 'lucide-svelte/icons/link';
import MapIcon from 'lucide-svelte/icons/map';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
import { MAPS, gridRef, mapInfo } from '#lib/pubg-map/shared.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const fmt = new Intl.DateTimeFormat('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const roomName = (id: number) => data.rooms.find((r) => r.id === id)?.name ?? 'deleted room';
// Landings over time: every archived landing per map, newest matches drawn last (on top).
const byMap = $derived(
MAPS.map((m) => ({
map: m,
matches: data.history.filter((h) => h.map === m.slug).toReversed()
})).filter((g) => g.matches.length)
);
let copied = $state<string | null>(null);
async function copy(code: string) {
await navigator.clipboard.writeText(`${location.origin}/trackers/games/pubg/map/${code}`);
copied = code;
setTimeout(() => (copied = null), 1500);
}
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a
href={resolve('/trackers/games/[slug]', { slug: 'pubg' })}
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
><ArrowLeft size={14} aria-hidden="true" /> PUBG</a
>
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
<MapIcon class="size-10 text-sky-400" aria-hidden="true" /> Drop map
</h1>
<p class="mb-10 max-w-2xl text-[var(--site-muted)]">
A live squad map for a second monitor: landings, the plane path, hotspots, vehicles and notes,
synced between everyone in the room as they click.
</p>
{#if !data.owner}
<p class="card p-6 text-[var(--site-muted)]">
Rooms are private. Ask Stan for the link to his squad’s room.
</p>
{:else}
<section class="mb-12">
<h2 class="mb-4 text-xl font-semibold">Rooms</h2>
<ul class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each data.rooms as room (room.id)}
<li class="card tile flex items-center gap-2 p-2 pl-4">
<a
class="min-w-0 flex-1 py-2"
href={resolve('/trackers/games/pubg/map/[code]', { code: room.code })}
>
<span class="block truncate font-medium">{room.name}</span>
<span class="block text-xs text-[var(--site-muted)]"
>{room.matches}
{room.matches === 1 ? 'match' : 'matches'} · since {fmt.format(
new Date(room.createdAt)
)}</span
>
</a>
<button class="icon" onclick={() => copy(room.code)} title="Copy share link"
><Link
size={16}
class={copied === room.code ? 'text-emerald-400' : 'text-sky-400'}
/></button
>
<form
method="POST"
action="?/deleteRoom"
use:enhance={({ cancel }) => {
if (!confirm(`Delete “${room.name}” and its whole history?`)) cancel();
}}
>
<input type="hidden" name="id" value={room.id} />
<button class="icon" title="Delete room"
><Trash2 size={16} class="text-rose-400" /></button
>
</form>
<a
class="icon"
href={resolve('/trackers/games/pubg/map/[code]', { code: room.code })}
title="Open"><ArrowRight size={16} /></a
>
</li>
{/each}
<li class="card p-2">
<form method="POST" action="?/createRoom" class="flex items-center gap-2">
<input
name="name"
maxlength="40"
placeholder="New room name (e.g. Squad)"
class="field min-w-0 flex-1"
/>
<button class="new"><Plus size={16} class="text-emerald-500" /> Create</button>
</form>
</li>
</ul>
<p class="mt-3 text-sm text-[var(--site-muted)]">
Friends join with the share link (no account): they pick a nickname and colour the first
time.
</p>
</section>
<section class="mb-12">
<h2 class="mb-4 text-xl font-semibold">Landings over time</h2>
{#if !byMap.length}
<p class="card p-6 text-[var(--site-muted)]">
No finished matches yet. “New match” on the map archives the current one here.
</p>
{/if}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each byMap as g (g.map.slug)}
<figure class="card overflow-hidden">
<div class="relative aspect-square">
<img
src="/pubg-tiles/{g.map.slug}/thumb.webp"
alt="{g.map.name} map"
class="absolute inset-0 size-full"
loading="lazy"
/>
<svg viewBox="0 0 1 1" class="absolute inset-0 size-full" aria-hidden="true">
{#each g.matches as h (h.id)}
{#if h.plane}<line
x1={h.plane.x}
y1={h.plane.y}
x2={h.plane.x2}
y2={h.plane.y2}
stroke="#38bdf8"
stroke-opacity="0.35"
stroke-width="0.004"
/>{/if}
{#each h.landings as l, i (i)}<circle
cx={l.x}
cy={l.y}
r="0.012"
fill={l.color}
stroke="#000"
stroke-width="0.003"
/>{/each}
{/each}
</svg>
</div>
<figcaption class="p-3 text-sm">
<b>{g.map.name}</b>
<span class="text-[var(--site-muted)]"
>· {g.matches.length}
{g.matches.length === 1 ? 'match' : 'matches'} · {g.matches.reduce(
(n, h) => n + h.landings.length,
0
)} landings</span
>
</figcaption>
</figure>
{/each}
</div>
</section>
{#if data.history.length}
<section>
<h2 class="mb-4 text-xl font-semibold">Match history</h2>
<ul class="flex flex-col gap-2">
{#each data.history as h (h.id)}
{@const m = mapInfo(h.map)}
<li class="card flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
<span class="w-44 shrink-0"
><b>#{h.number} {m.name}</b><span class="block text-xs text-[var(--site-muted)]"
>{fmt.format(new Date(h.startedAt))} · {roomName(h.roomId)}</span
></span
>
<span class="flex flex-1 flex-wrap gap-1.5">
{#each h.landings as l, i (i)}
<span class="chip" style:--c={l.color}
>{l.player} <i>{gridRef(m, l.x, l.y)}</i></span
>
{:else}<span class="text-sm text-[var(--site-muted)]">no landings marked</span
>{/each}
</span>
<span class="text-xs text-[var(--site-muted)]">{h.markers} markers</span>
<form
method="POST"
action="?/deleteMatch"
use:enhance={({ cancel }) => {
if (!confirm(`Delete match #${h.number}?`)) cancel();
}}
>
<input type="hidden" name="id" value={h.id} />
<button class="icon" title="Delete this match"
><Trash2 size={15} class="text-rose-400" /></button
>
</form>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
<p class="mt-12 text-xs text-[var(--site-muted)]">
Map images © KRAFTON, Inc., from PUBG’s official
<a class="underline" href="https://github.com/pubg/api-assets" target="_blank" rel="noreferrer"
>api-assets</a
>, used under the
<a class="underline" href="https://developer.pubg.com/tos" target="_blank" rel="noreferrer"
>PUBG Developer API terms</a
>
and
<a
class="underline"
href="https://www.pubg.com/en/clause/content_creation_guideline"
target="_blank"
rel="noreferrer">content creation guideline</a
>. An unofficial, non-commercial fan tool; not affiliated with or endorsed by KRAFTON.
</p>
</div>
<style>
.card {
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
}
.tile {
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.tile:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-2px);
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 0.55);
}
.icon {
display: inline-grid;
place-items: center;
width: 2.1rem;
height: 2.1rem;
border-radius: 0.6rem;
transition: background-color 140ms ease;
}
.icon:hover {
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.field {
height: 2.4rem;
padding: 0 0.8rem;
border-radius: 0.6rem;
background: transparent;
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
}
.new {
display: inline-flex;
align-items: center;
gap: 0.35rem;
height: 2.4rem;
padding: 0 0.9rem;
border-radius: 0.6rem;
background: var(--foreground);
color: var(--background);
font-weight: 600;
}
.chip {
font-size: 0.8rem;
padding: 0.05rem 0.55rem;
border-radius: 999px;
background: color-mix(in oklab, var(--c) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--c) 45%, transparent);
}
.chip i {
font-style: normal;
color: var(--site-muted);
}
@media (prefers-reduced-motion: reduce) {
.tile,
.tile:hover {
transition: none;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,22 @@
import { error } from '@sveltejs/kit';
import { ROOM_CODE_RE } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import type { PageServerLoad } from './$types';
// One drop map room. The code in the URL is the access: anyone with the link can mark this
// room's current match (live over /ws/pubg-map); unknown codes are a plain 404.
export const load: PageServerLoad = async ({ params, setHeaders }) => {
if (!ROOM_CODE_RE.test(params.code)) error(404, 'Not Found');
const room = await store.roomByCode(params.code);
if (!room) error(404, 'Not Found');
setHeaders({ 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex, nofollow' });
return {
room: { code: room.code, name: room.name },
history: await store.pastMatches({ roomId: room.id, limit: 100 }),
seo: {
title: `${room.name} · PUBG drop map`,
description: 'Live PUBG squad map.',
index: false
}
};
};

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { resolve } from '$app/paths';
import PubgMap from '#lib/pubg-map/PubgMap.svelte';
import type { PageProps } from './$types';
// Full-screen (no site header, no games switcher): made for a second monitor.
let { data }: PageProps = $props();
</script>
<PubgMap
code={data.room.code}
roomName={data.room.name}
history={data.history}
backHref={resolve('/trackers/games/[slug]', { slug: 'pubg' })}
/>

View File

@@ -0,0 +1,14 @@
import { error, json } from '@sveltejs/kit';
import { ROOM_CODE_RE } from '#lib/pubg-map/shared.js';
import * as store from '#lib/server/pubg-map/store.js';
import type { RequestHandler } from './$types';
/** A room's finished matches with their landings (refetched by the map after "New match"). */
export const GET: RequestHandler = async ({ params }) => {
if (!ROOM_CODE_RE.test(params.code)) error(404, 'Not Found');
const room = await store.roomByCode(params.code);
if (!room) error(404, 'Not Found');
return json(await store.pastMatches({ roomId: room.id, limit: 100 }), {
headers: { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex' }
});
};