mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[www] maybe add a fun little globe of players
This commit is contained in:
@@ -69,6 +69,82 @@ export const PRESENCE_SCHEMA_DDL: string[] = [
|
||||
`CREATE INDEX IF NOT EXISTS idx_presence_expires ON presence (expires_at)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How coarse a stored player location is, in degrees — about 55km at the equator.
|
||||
*
|
||||
* Coordinates are snapped to this grid BEFORE they are written, so nothing finer than a
|
||||
* grid cell ever reaches the database. The only thing that reads them is the globe on
|
||||
* the website, where the whole earth renders a few hundred pixels across and a cell is
|
||||
* comfortably under one pixel — so the blur costs the picture nothing, and a leak of the
|
||||
* presence table still can't put anybody in a particular town.
|
||||
*/
|
||||
export const GEO_GRID_DEGREES = 0.5
|
||||
|
||||
/**
|
||||
* A live player's approximate location, derived from the IP their request arrived on and
|
||||
* stored INSTEAD of it — presence never holds an address, and nothing downstream can
|
||||
* recover one from this.
|
||||
*
|
||||
* Cloudflare resolves the address at the edge and hands us the result on `request.cf`, so
|
||||
* there's no third-party lookup and no IP for our own code to handle. What arrives is
|
||||
* already city-grade at best; {@link presenceGeoFromCf} snaps it to GEO_GRID_DEGREES on
|
||||
* top of that.
|
||||
*/
|
||||
export interface PresenceGeo {
|
||||
/** Latitude, snapped to the grid. */
|
||||
lat: number
|
||||
/** Longitude, snapped to the grid. */
|
||||
lon: number
|
||||
/** ISO 3166-1 alpha-2, uppercased — `XX` when the edge didn't name a real country. */
|
||||
country: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of `request.cf` a location is read from — the whole of the contract with the
|
||||
* edge, in one place. Cloudflare sends all three as strings.
|
||||
*/
|
||||
export interface GeoProperties {
|
||||
latitude?: string | null
|
||||
longitude?: string | null
|
||||
country?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The location to stamp on a presence row, or null when the request carries none.
|
||||
*
|
||||
* Null is the ordinary case in local dev (miniflare sets no `cf`) and for any address the
|
||||
* edge can't place, so callers carry the row's previous location forward rather than
|
||||
* blanking it — a player who keeps heartbeating shouldn't drop off the globe because one
|
||||
* request arrived without geolocation.
|
||||
*/
|
||||
export function presenceGeoFromCf(properties: unknown): PresenceGeo | null {
|
||||
// `unknown` rather than {@link GeoProperties}, because callers pass `c.req.raw.cf`,
|
||||
// which Hono types as the union of the incoming and OUTGOING `cf` shapes — and the
|
||||
// outgoing one has no geolocation on it at all, so nothing narrower accepts the
|
||||
// argument every caller actually has. Every read below already tolerates a miss.
|
||||
const cf = properties as GeoProperties | undefined | null
|
||||
const lat = Number.parseFloat(String(cf?.latitude ?? ''))
|
||||
const lon = Number.parseFloat(String(cf?.longitude ?? ''))
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
|
||||
// `country` is alpha-2 for a real country and something else for the pseudo-countries
|
||||
// (`T1` is Tor); the shape test is what sorts them, so a new one can't leak through.
|
||||
const country = typeof cf?.country === 'string' ? cf.country.toUpperCase() : ''
|
||||
return {
|
||||
lat: snapToGeoGrid(lat),
|
||||
lon: snapToGeoGrid(lon),
|
||||
country: /^[A-Z]{2}$/.test(country) ? country : 'XX',
|
||||
}
|
||||
}
|
||||
|
||||
/** Round a coordinate onto the GEO_GRID_DEGREES grid. */
|
||||
function snapToGeoGrid(value: number): number {
|
||||
// Re-rounded through toFixed because binary floats don't land on clean multiples
|
||||
// (34.9 / 0.5 * 0.5 is 34.900000000000006), and two spellings of one cell would GROUP
|
||||
// BY into two pins sitting on top of each other. Adding 0 normalises -0 to 0, which
|
||||
// would otherwise be a third.
|
||||
return Number((Math.round(value / GEO_GRID_DEGREES) * GEO_GRID_DEGREES + 0).toFixed(4))
|
||||
}
|
||||
|
||||
/**
|
||||
* The presence a caller writes — the room instance the player is in plus the
|
||||
* status fields the heartbeat echoes. Generic over the room-instance shape so each
|
||||
@@ -89,6 +165,12 @@ export interface PresenceInput<TRoomInstance = unknown> {
|
||||
* matchmake supplies one.
|
||||
*/
|
||||
loginLock?: string
|
||||
/**
|
||||
* Roughly where the player is, from the IP the write arrived on (see
|
||||
* {@link PresenceGeo}). Absent when the request carried no geolocation, and only ever
|
||||
* read in aggregate — the website's globe counts players per grid cell.
|
||||
*/
|
||||
geo?: PresenceGeo
|
||||
}
|
||||
|
||||
/** A stored presence row — the input plus its absolute expiry (epoch seconds). */
|
||||
@@ -190,6 +272,46 @@ export async function countOnlinePlayers(db: D1Database, now = nowSeconds()): Pr
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the players who are online right now are, one entry per populated
|
||||
* GEO_GRID_DEGREES cell — the pins behind the website's globe.
|
||||
*
|
||||
* Aggregated in SQL rather than by reading the rows out, so what leaves the database is
|
||||
* already a count per cell: no caller ever holds a list of individual players and their
|
||||
* locations, which is the point (see {@link PresenceGeo}). Rows written before geo
|
||||
* existed, and players the edge couldn't place, have no `$.geo` and are simply left out —
|
||||
* so the totals here can be lower than {@link countOnlinePlayers}, and callers should
|
||||
* report both rather than passing this sum off as the player count.
|
||||
*
|
||||
* Grouped on the JSON path rather than a generated column: `presence` is bounded by
|
||||
* account count and this is a once-per-poll read, so the scan is cheaper than a schema
|
||||
* change on a table another worker owns.
|
||||
*/
|
||||
export async function countOnlinePlayersByLocation(
|
||||
db: D1Database,
|
||||
now = nowSeconds()
|
||||
): Promise<PresenceLocation[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT json_extract(data, '$.geo.lat') AS lat,
|
||||
json_extract(data, '$.geo.lon') AS lon,
|
||||
json_extract(data, '$.geo.country') AS country,
|
||||
COUNT(*) AS players
|
||||
FROM presence
|
||||
WHERE expires_at > ?1 AND json_extract(data, '$.geo.lat') IS NOT NULL
|
||||
GROUP BY lat, lon, country
|
||||
ORDER BY players DESC, country, lat, lon`
|
||||
)
|
||||
.bind(now)
|
||||
.all<PresenceLocation>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** One populated grid cell — a pin on the globe, and how many players are in it. */
|
||||
export interface PresenceLocation extends PresenceGeo {
|
||||
players: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Live head-count per ROOM, keyed by room id — the players standing in any of a
|
||||
* room's instances right now. One grouped query rather than a count per room, so
|
||||
|
||||
Reference in New Issue
Block a user