[www] maybe add a fun little globe of players

This commit is contained in:
Devin Zuczek
2026-08-25 19:33:11 -04:00
parent 4bacdb0312
commit 0f726b35da
11 changed files with 787 additions and 12 deletions
+122
View File
@@ -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