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