PUBG drop map: live squad map with WebSocket rooms
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
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:
@@ -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"]
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||||
|
|||||||
37
drizzle/20261025223611_pubg_map/migration.sql
Normal file
37
drizzle/20261025223611_pubg_map/migration.sql
Normal 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;
|
||||||
36138
drizzle/20261025223611_pubg_map/snapshot.json
Normal file
36138
drizzle/20261025223611_pubg_map/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
41
scripts/build-pubg-tiles.py
Normal file
41
scripts/build-pubg-tiles.py
Normal 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)
|
||||||
@@ -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
74
server.js
Normal 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');
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
1549
src/lib/pubg-map/PubgMap.svelte
Normal file
1549
src/lib/pubg-map/PubgMap.svelte
Normal file
File diff suppressed because it is too large
Load Diff
38
src/lib/pubg-map/pins.ts
Normal file
38
src/lib/pubg-map/pins.ts
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!
|
||||||
|
);
|
||||||
|
|
||||||
|
export const initials = (name: string) =>
|
||||||
|
name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((w) => [...w][0] ?? '')
|
||||||
|
.join('')
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
51
src/lib/pubg-map/shared.test.ts
Normal file
51
src/lib/pubg-map/shared.test.ts
Normal 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
220
src/lib/pubg-map/shared.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
62
src/lib/server/db/schema/pubg-map.ts
Normal file
62
src/lib/server/db/schema/pubg-map.ts
Normal 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)]
|
||||||
|
);
|
||||||
12
src/lib/server/pubg-map/code.ts
Normal file
12
src/lib/server/pubg-map/code.ts
Normal 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;
|
||||||
|
}
|
||||||
269
src/lib/server/pubg-map/live.ts
Normal file
269
src/lib/server/pubg-map/live.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
183
src/lib/server/pubg-map/store.ts
Normal file
183
src/lib/server/pubg-map/store.ts
Normal 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
64
src/lib/server/ws/hub.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
53
src/routes/trackers/games/pubg/map/+page.server.ts
Normal file
53
src/routes/trackers/games/pubg/map/+page.server.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
295
src/routes/trackers/games/pubg/map/+page.svelte
Normal file
295
src/routes/trackers/games/pubg/map/+page.svelte
Normal 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>
|
||||||
22
src/routes/trackers/games/pubg/map/[code]/+page.server.ts
Normal file
22
src/routes/trackers/games/pubg/map/[code]/+page.server.ts
Normal 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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
15
src/routes/trackers/games/pubg/map/[code]/+page@.svelte
Normal file
15
src/routes/trackers/games/pubg/map/[code]/+page@.svelte
Normal 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' })}
|
||||||
|
/>
|
||||||
14
src/routes/trackers/games/pubg/map/[code]/history/+server.ts
Normal file
14
src/routes/trackers/games/pubg/map/[code]/history/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user