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:
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) });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user