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

@@ -4,7 +4,11 @@
* only imports SiteChrome for other pages.
*/
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. */

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 './dataholders';
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;
}