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:
@@ -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. */
|
||||
|
||||
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 './dataholders';
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user