Files
website/src/lib/server/pubg-map/live.ts
Stan Runge 0fe0d36f87
All checks were successful
Deploy production / deploy (push) Successful in 1m15s
PUBG drop map: live squad map with WebSocket rooms
- 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
2026-10-25 22:46:30 +00:00

270 lines
7.8 KiB
TypeScript

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) });
}
});
}