mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[www] maybe add a fun little globe of players
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@scalar/api-reference": "1.63.0",
|
||||
"cobe": "2.0.1",
|
||||
"hono": "4.12.27",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
+400
-4
@@ -1,3 +1,4 @@
|
||||
import createGlobe from 'cobe'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Accessibility } from '@repo/domain/src/enums'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SOURCE_REPO,
|
||||
} from '../links'
|
||||
|
||||
import type { COBEOptions, Globe, Marker } from 'cobe'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
@@ -777,6 +779,7 @@ function HomePage({
|
||||
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
|
||||
<div className="shell home">
|
||||
<About slides={feed.slides} error={feed.error} />
|
||||
<PlayersWorldwide />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
@@ -943,6 +946,399 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- Who's playing, and where ------------------------------------------- */
|
||||
|
||||
/** One pin from `/server-status/locations`: a grid cell and how many players are in it. */
|
||||
interface Pin {
|
||||
lat: number
|
||||
lon: number
|
||||
/** ISO 3166-1 alpha-2, or `XX` when the edge couldn't name a country. */
|
||||
country: string
|
||||
players: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole answer from `/server-status/locations`. `players` is everyone online and
|
||||
* `located` only those with a pin — a player the edge couldn't place is counted in the
|
||||
* first and missing from the second, so the section can say so rather than quietly
|
||||
* showing a smaller number than the rest of the page.
|
||||
*/
|
||||
interface WorldPresence {
|
||||
players: number
|
||||
located: number
|
||||
pins: Pin[]
|
||||
}
|
||||
|
||||
/** How often the globe re-asks who's online. */
|
||||
const GLOBE_POLL_MS = 30_000
|
||||
|
||||
/**
|
||||
* Poll `www` for where the online players are. `presence === null` means the first
|
||||
* answer hasn't landed yet.
|
||||
*
|
||||
* Same-origin, so unlike the photo feed this doesn't wait on the config — `www` serves
|
||||
* it itself. Polling stops while the tab is hidden and asks again on the way back, so a
|
||||
* page left open in a background tab overnight isn't a few thousand requests. A failed
|
||||
* poll keeps the last good answer on screen: a globe that empties out because one
|
||||
* request timed out reads as "everyone left", which is worse than being 30s stale.
|
||||
*/
|
||||
function useWorldPresence(): { presence: WorldPresence | null; error: string } {
|
||||
const [presence, setPresence] = useState<WorldPresence | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
// Function declarations, not consts: `schedule` names `poll` and `poll` names
|
||||
// `schedule`, and hoisting is what lets them be written in reading order.
|
||||
function schedule() {
|
||||
clearTimeout(timer)
|
||||
if (!live || document.hidden) return
|
||||
timer = setTimeout(poll, GLOBE_POLL_MS)
|
||||
}
|
||||
|
||||
function poll() {
|
||||
call<WorldPresence>('/server-status/locations')
|
||||
.then((next) => {
|
||||
if (!live) return
|
||||
setPresence(next)
|
||||
setError('')
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (live) setError(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(schedule)
|
||||
}
|
||||
|
||||
// Coming back to a tab that was away: answer now, rather than after a timer that
|
||||
// was deliberately never armed while it was hidden.
|
||||
const onVisibility = () => {
|
||||
if (!document.hidden) poll()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
poll()
|
||||
|
||||
return () => {
|
||||
live = false
|
||||
clearTimeout(timer)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { presence, error }
|
||||
}
|
||||
|
||||
/** Radians the globe turns per frame when nobody is steering it. */
|
||||
const GLOBE_SPIN_PER_FRAME = 0.0028
|
||||
/** Radians per pixel of drag — cobe's own demo figure, and it feels right. */
|
||||
const GLOBE_DRAG_PER_PX = 1 / 200
|
||||
/**
|
||||
* Where the spin starts. Arbitrary — the globe turns continuously, so this only decides
|
||||
* which face the first second shows; nudge it if that first face keeps landing on ocean.
|
||||
*/
|
||||
const GLOBE_START_PHI = 4.1
|
||||
|
||||
/** A pin's dot size, from the smallest that reads to one that still isn't a blob. */
|
||||
const PIN_MIN_SIZE = 0.028
|
||||
const PIN_MAX_SIZE = 0.075
|
||||
|
||||
/**
|
||||
* Pins → cobe markers. Sized by head-count against the busiest cell so a crowd reads as
|
||||
* one, on a square root because area is what the eye compares: scaling the radius
|
||||
* linearly makes four players look sixteen times the size of one.
|
||||
*/
|
||||
function pinMarkers(pins: Pin[]): Marker[] {
|
||||
const busiest = pins.reduce((n, pin) => Math.max(n, pin.players), 1)
|
||||
return pins.map((pin) => ({
|
||||
location: [pin.lat, pin.lon],
|
||||
size: PIN_MIN_SIZE + (PIN_MAX_SIZE - PIN_MIN_SIZE) * Math.sqrt(pin.players / busiest),
|
||||
}))
|
||||
}
|
||||
|
||||
/** cobe wants colours as 0–1 RGB triples, so the palette is repeated here in its terms. */
|
||||
const GLOBE_THEME = {
|
||||
// Warm dark: the surface the screenshots are lit against (--surface-hi / --line).
|
||||
dark: {
|
||||
dark: 1,
|
||||
baseColor: [0.21, 0.17, 0.13],
|
||||
glowColor: [0.31, 0.24, 0.17],
|
||||
markerColor: [1, 0.44, 0.004], // --accent #FE7101
|
||||
mapBrightness: 5.4,
|
||||
},
|
||||
light: {
|
||||
dark: 0,
|
||||
baseColor: [0.86, 0.84, 0.81],
|
||||
glowColor: [1, 0.99, 0.97],
|
||||
markerColor: [0.88, 0.37, 0], // --accent #E05F00
|
||||
mapBrightness: 2.2,
|
||||
},
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The globe itself: a dotted earth with a pin per populated cell, spinning slowly and
|
||||
* draggable.
|
||||
*
|
||||
* Drawn by `cobe`, a ~13KB WebGL globe that takes markers as plain lat/lon and does the
|
||||
* projection — no three.js, no map tiles and no network of its own, which is what makes
|
||||
* it affordable on a page whose point is the hero photo above it.
|
||||
*
|
||||
* Purely the picture: every number it shows lives in the list beside it too, so a
|
||||
* browser with no WebGL (or a reader who isn't looking at pixels) loses nothing. That's
|
||||
* also why the canvas is aria-hidden rather than labelled.
|
||||
*/
|
||||
function PlayerGlobe({ pins }: { pins: Pin[] }) {
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() =>
|
||||
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark'
|
||||
)
|
||||
|
||||
// New markers are handed to the running globe rather than rebuilding it, so a poll
|
||||
// doesn't restart the spin. The flag is what keeps the buffer upload to the frames
|
||||
// where something actually changed instead of all sixty a second.
|
||||
const markers = useRef<Marker[]>(pinMarkers(pins))
|
||||
const markersChanged = useRef(true)
|
||||
useEffect(() => {
|
||||
markers.current = pinMarkers(pins)
|
||||
markersChanged.current = true
|
||||
}, [pins])
|
||||
|
||||
// How far the pointer has dragged the globe, in radians. A ref, not state: it changes
|
||||
// on every pointermove and the animation loop is the only thing that reads it, so
|
||||
// re-rendering React for it would be sixty wasted renders a second.
|
||||
const nudge = useRef(0)
|
||||
const dragFrom = useRef<number | null>(null)
|
||||
|
||||
// The site follows the system theme with no toggle of its own (see styles.css), so
|
||||
// this listens for the same switch the CSS does and rebuilds the globe in the other
|
||||
// palette — cobe takes its colours at creation.
|
||||
useEffect(() => {
|
||||
if (typeof matchMedia !== 'function') return
|
||||
const query = matchMedia('(prefers-color-scheme: light)')
|
||||
const onChange = () => setTheme(query.matches ? 'light' : 'dark')
|
||||
query.addEventListener('change', onChange)
|
||||
return () => query.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const surface = canvas.current
|
||||
const frame = box.current
|
||||
if (!surface || !frame) return
|
||||
|
||||
let globe: Globe | null = null
|
||||
let request = 0
|
||||
let phi = GLOBE_START_PHI
|
||||
let size = 0
|
||||
let sizeChanged = false
|
||||
|
||||
// The auto-spin is decoration, and a globe that never stops moving is exactly what
|
||||
// this setting is for. The pins (and the drag) still work.
|
||||
const still =
|
||||
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
function draw() {
|
||||
if (!globe) return
|
||||
// Only the parts that changed: cobe reallocates the drawing buffer whenever it's
|
||||
// handed a width, which would clear the canvas on every single frame.
|
||||
const next: Partial<COBEOptions> = {}
|
||||
if (sizeChanged) {
|
||||
next.width = size
|
||||
next.height = size
|
||||
sizeChanged = false
|
||||
}
|
||||
if (markersChanged.current) {
|
||||
next.markers = markers.current
|
||||
markersChanged.current = false
|
||||
}
|
||||
// A hand on the globe stops the drift, and it picks back up from wherever it was
|
||||
// let go rather than snapping to where it would have got to.
|
||||
if (!still && dragFrom.current === null) phi += GLOBE_SPIN_PER_FRAME
|
||||
next.phi = phi + nudge.current
|
||||
globe.update(next)
|
||||
request = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
function begin() {
|
||||
// Nothing to draw into yet — the observer calls back again once there is.
|
||||
if (globe || size === 0) return
|
||||
try {
|
||||
globe = createGlobe(surface!, {
|
||||
devicePixelRatio: Math.min(devicePixelRatio || 1, 2),
|
||||
width: size,
|
||||
height: size,
|
||||
phi,
|
||||
// Tilted a little north: most of the pins are, and a globe seen dead-on from
|
||||
// the equator reads as a flat circle.
|
||||
theta: 0.22,
|
||||
diffuse: 1.2,
|
||||
mapSamples: 14000,
|
||||
markers: markers.current,
|
||||
...GLOBE_THEME[theme],
|
||||
// The palette is readonly (`as const`), which the option type isn't.
|
||||
baseColor: [...GLOBE_THEME[theme].baseColor],
|
||||
glowColor: [...GLOBE_THEME[theme].glowColor],
|
||||
markerColor: [...GLOBE_THEME[theme].markerColor],
|
||||
})
|
||||
} catch {
|
||||
// No WebGL, or a context the browser refused to give. The list beside this
|
||||
// carries every number the globe was going to show, so drop the canvas and
|
||||
// leave the section otherwise intact.
|
||||
setFailed(true)
|
||||
return
|
||||
}
|
||||
markersChanged.current = false
|
||||
request = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
// Square, and sized from the layout rather than from a constant, so the globe fills
|
||||
// its column at every breakpoint instead of being letterboxed on one of them.
|
||||
const observer = new ResizeObserver(() => {
|
||||
const width = Math.round(frame.clientWidth)
|
||||
if (width === 0 || width === size) return
|
||||
size = width
|
||||
sizeChanged = true
|
||||
begin()
|
||||
})
|
||||
observer.observe(frame)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
cancelAnimationFrame(request)
|
||||
globe?.destroy()
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
if (failed) return null
|
||||
|
||||
return (
|
||||
<div className="globe-frame" ref={box}>
|
||||
<canvas
|
||||
className="globe-canvas"
|
||||
ref={canvas}
|
||||
// Decorative: `PlayersWorldwide` states the head-count in words and lists every
|
||||
// country beside it, so there is nothing here for a screen reader to miss.
|
||||
aria-hidden="true"
|
||||
onPointerDown={(e) => {
|
||||
dragFrom.current = e.clientX
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (dragFrom.current === null) return
|
||||
nudge.current += (e.clientX - dragFrom.current) * GLOBE_DRAG_PER_PX
|
||||
dragFrom.current = e.clientX
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
dragFrom.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
dragFrom.current = null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Country codes to names, once — building an Intl formatter per row is not free. */
|
||||
const countryNames =
|
||||
typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['en'], { type: 'region' }) : null
|
||||
|
||||
/** A country code as something to read. `XX` is the edge declining to name one. */
|
||||
function countryName(code: string): string {
|
||||
if (code === 'XX') return 'Somewhere else'
|
||||
return countryNames?.of(code) ?? code
|
||||
}
|
||||
|
||||
/** Players per country, busiest first — the pins in a cell-by-cell list's stead. */
|
||||
function byCountry(pins: Pin[]): Array<{ country: string; players: number }> {
|
||||
const totals = new Map<string, number>()
|
||||
for (const pin of pins) totals.set(pin.country, (totals.get(pin.country) ?? 0) + pin.players)
|
||||
return [...totals]
|
||||
.map(([country, players]) => ({ country, players }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.players - a.players || countryName(a.country).localeCompare(countryName(b.country))
|
||||
)
|
||||
}
|
||||
|
||||
/** How many countries to name before the rest become "and n more". */
|
||||
const COUNTRY_ROWS = 6
|
||||
|
||||
/**
|
||||
* "People are playing this right now, from all over" — the claim the rest of the page
|
||||
* makes in words, shown instead.
|
||||
*
|
||||
* The globe is the illustration and the list is the content: everything the pins say is
|
||||
* written out beside them, which is what lets the canvas be decorative (and lets the
|
||||
* whole thing degrade to a list where WebGL isn't available).
|
||||
*/
|
||||
function PlayersWorldwide() {
|
||||
const { presence, error } = useWorldPresence()
|
||||
const pins = presence?.pins ?? []
|
||||
const countries = byCountry(pins)
|
||||
|
||||
return (
|
||||
<section className="globe" aria-labelledby="globe-title">
|
||||
<div className="globe-copy">
|
||||
<h2 className="about-title" id="globe-title">
|
||||
Somebody is playing right now
|
||||
</h2>
|
||||
{presence === null ? (
|
||||
<p className="about-lede">
|
||||
{error ? "Can't reach the servers to ask who's online." : 'Counting who’s on…'}
|
||||
</p>
|
||||
) : presence.located === 0 ? (
|
||||
<p className="about-lede">
|
||||
{presence.players > 0
|
||||
? `${presence.players.toLocaleString()} online — nobody placed on the map yet.`
|
||||
: 'Nobody is online this second. The servers are up; be the first one on.'}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="globe-count">
|
||||
<strong>{presence.located.toLocaleString()}</strong>{' '}
|
||||
{presence.located === 1 ? 'player' : 'players'} in {countries.length}{' '}
|
||||
{countries.length === 1 ? 'country' : 'countries'}, right now.
|
||||
</p>
|
||||
<ul className="globe-list">
|
||||
{countries.slice(0, COUNTRY_ROWS).map((row) => (
|
||||
<li key={row.country}>
|
||||
<span>{countryName(row.country)}</span>
|
||||
<span className="globe-tally">{row.players.toLocaleString()}</span>
|
||||
</li>
|
||||
))}
|
||||
{countries.length > COUNTRY_ROWS && (
|
||||
<li className="globe-more">
|
||||
<span>and {countries.length - COUNTRY_ROWS} more</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* The head-count and the map can disagree — say which, rather than
|
||||
letting the smaller number look like the answer. */}
|
||||
{presence.players > presence.located && (
|
||||
<p className="globe-note">
|
||||
{presence.players - presence.located} more online from somewhere we couldn't
|
||||
place.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Not a disclaimer in the footer: people see a map of themselves and want to
|
||||
know how precise it is, so it says so where they're looking. */}
|
||||
<p className="globe-note">
|
||||
Pins are rounded to about 55km before anyone stores them, and nobody's address is
|
||||
kept — see the <a href="/privacy">privacy policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
<PlayerGlobe pins={pins} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sign-in page — sign in, plus create-account when the server says signup is open
|
||||
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
|
||||
@@ -1382,10 +1778,10 @@ function BlobUpload({
|
||||
<span className="badge beta">Beta</span>
|
||||
</p>
|
||||
<p className="muted blob-upload-caveat">
|
||||
New and lightly tested. Nothing here checks the file — the server stores whatever it
|
||||
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
|
||||
scene data from a room built on anything newer may not load at all. Download the save
|
||||
above and keep it before replacing it.
|
||||
New and lightly tested. Nothing here checks the file — the server stores whatever it is and
|
||||
the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
|
||||
from a room built on anything newer may not load at all. Download the save above and keep it
|
||||
before replacing it.
|
||||
</p>
|
||||
<label className="blob-upload-file">
|
||||
Scene data file
|
||||
|
||||
@@ -403,6 +403,118 @@ body {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
/* ---- Who's playing, and where ------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The same split as .about, mirrored: the numbers on the left, the globe on the right,
|
||||
* under the photo that's already on that side. The globe is the illustration and the
|
||||
* list is the content — everything the pins say is written out beside them, so the
|
||||
* section still reads with no WebGL (PlayerGlobe renders nothing at all in that case,
|
||||
* and the copy column simply takes the width).
|
||||
*/
|
||||
.globe {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 380px);
|
||||
gap: 32px 48px;
|
||||
align-items: center;
|
||||
padding: 8px 0 56px;
|
||||
}
|
||||
|
||||
.globe-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The one number the section exists to say. Tabular figures so a poll landing on a
|
||||
different count doesn't shift the words after it. */
|
||||
.globe-count {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.globe-count strong {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Country, then tally, on rules rather than in a box: this sits directly under the
|
||||
lede and a bordered card here would read as a second, competing surface. */
|
||||
.globe-list {
|
||||
list-style: none;
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.globe-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.globe-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.globe-tally {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.globe-more {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.globe-note {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.globe-note a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Square and sized from the layout: PlayerGlobe measures this box and hands cobe the
|
||||
width, so the canvas has to be told its own size in CSS rather than inheriting one
|
||||
from the drawing buffer. */
|
||||
.globe-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.globe-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* The globe is draggable, so it says so — and never steals a page scroll on touch,
|
||||
which `touch-action: none` on a full-width element would. */
|
||||
cursor: grab;
|
||||
touch-action: pan-y;
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.globe-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-block;
|
||||
font-family: var(--body);
|
||||
@@ -1098,6 +1210,20 @@ button[type='submit']:disabled {
|
||||
gap: 26px;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
/* One column too — but the globe goes first: stacked, it's the thing worth
|
||||
scrolling to, and a list of countries above it reads as a table of nothing. */
|
||||
.globe {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.globe-frame {
|
||||
order: -1;
|
||||
justify-self: center;
|
||||
max-width: 340px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
|
||||
*/
|
||||
|
||||
/** Last substantive revision, shown in the header. Bump when the text changes. */
|
||||
const EFFECTIVE_DATE = '26 July 2026'
|
||||
const EFFECTIVE_DATE = '25 August 2026'
|
||||
|
||||
/** The palette and type of the main site, inlined — this page loads no stylesheet. */
|
||||
const STYLES = `
|
||||
@@ -246,6 +246,7 @@ export function privacyPage(): string {
|
||||
<li>Your relationships with other players — friends, invites and blocks — and your interactions with rooms, such as favourites and cheers.</li>
|
||||
<li>Your in-game economy: token balance, inventory, outfits and gifts received.</li>
|
||||
<li>Your presence — which room instance you are currently in — so friends can find you and join. Presence records expire automatically on their own.</li>
|
||||
<li>An approximate location, worked out by our hosting provider from the IP address your game connects on, and stored on that presence record <em>instead of</em> the address. It is rounded to roughly 55 kilometres before it is stored, so it identifies a region, not a place — and it disappears with the presence record when you stop playing.</li>
|
||||
<li>Your player settings and preferences.</li>
|
||||
</ul>
|
||||
|
||||
@@ -254,6 +255,7 @@ export function privacyPage(): string {
|
||||
<li><strong>To run the game.</strong> Nearly everything above exists so the world can be reassembled the next time you log in — your avatar, your rooms, your inventory, your photos, your conversations.</li>
|
||||
<li><strong>To sign you in.</strong> Your platform identity, password hash and session tokens are what prove an account is yours and stop anyone else using it.</li>
|
||||
<li><strong>To let players find each other.</strong> Presence, friend lists and public feeds — including the photo slideshow on this website's front page, which shows public in-game photos along with the username of the player who took each one.</li>
|
||||
<li><strong>To show that people are playing.</strong> This website's front page has a globe of where the players who are online right now are. It is drawn from the approximate locations above, counted per region before it leaves the server — so it shows how many players are in an area, never who they are, and no address or individual location is ever sent to the page.</li>
|
||||
<li><strong>To keep the server usable.</strong> IP addresses, device identifiers and logs are used to investigate abuse, ban evasion and bugs, and to limit how many accounts can be created from one place. This is the only reason we keep them.</li>
|
||||
<li><strong>To contact you, if you asked us to.</strong> An email address you add is used for account recovery and account notices, nothing else.</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
|
||||
import {
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
presenceGeoFromCf,
|
||||
} from '@repo/domain/src/presence-db'
|
||||
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
import { turnstileKeys } from '../../turnstile'
|
||||
import { postAuthForm, readAuthError } from '../../upstream'
|
||||
|
||||
import type { PresenceGeo } from '@repo/domain/src/presence-db'
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -260,6 +265,68 @@ it('serves a public head-count of the players actually online', async () => {
|
||||
expect(await res.json()).toEqual({ status: 'online', players: 2 })
|
||||
})
|
||||
|
||||
// The globe on the front page. Two things are pinned here that a rendering bug wouldn't
|
||||
// catch: that the response carries COUNTS per grid cell and no per-player row (the whole
|
||||
// reason locations are stored coarsened in the first place), and that `players` and
|
||||
// `located` are allowed to disagree — a player the edge couldn't place is online without
|
||||
// being on the map, and the page says so rather than showing the smaller number.
|
||||
it('serves player locations as counts per grid cell, never per player', async () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await env.DB.prepare('DELETE FROM presence').run()
|
||||
const write = (accountId: number, expiresAt: number, geo: PresenceGeo | null) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId, roomInstance: null, expiresAt, geo: geo ?? undefined }))
|
||||
.run()
|
||||
|
||||
const live = now + PRESENCE_TTL_SECONDS
|
||||
// Two players in one cell, one in another, one online but unplaceable, one lapsed.
|
||||
await write(1, live, { lat: 34, lon: -118.5, country: 'US' })
|
||||
await write(2, live, { lat: 34, lon: -118.5, country: 'US' })
|
||||
await write(3, live, { lat: 51.5, lon: 0, country: 'GB' })
|
||||
await write(4, live, null)
|
||||
await write(5, now - 1, { lat: 34, lon: -118.5, country: 'US' })
|
||||
|
||||
const res = await SELF.fetch('https://example.com/server-status/locations', {
|
||||
headers: { origin: 'https://s.example' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// Public like the head-count beside it.
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(await res.json()).toEqual({
|
||||
// Everyone unexpired, including the player with no location…
|
||||
players: 4,
|
||||
// …who is the reason these two differ.
|
||||
located: 3,
|
||||
// Busiest cell first, and the two in one cell are ONE pin — not two rows that
|
||||
// happen to share coordinates, which would be a per-player list in disguise.
|
||||
pins: [
|
||||
{ lat: 34, lon: -118.5, country: 'US', players: 2 },
|
||||
{ lat: 51.5, lon: 0, country: 'GB', players: 1 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
// The blur is applied on the way IN, so the database itself never holds a fine
|
||||
// coordinate — pinned because doing it at read time would look identical from the
|
||||
// outside and be worth much less.
|
||||
it('snaps a location to the grid and refuses to name a pseudo-country', () => {
|
||||
expect(presenceGeoFromCf({ latitude: '34.0522', longitude: '-118.2437', country: 'US' })).toEqual(
|
||||
{ lat: 34, lon: -118, country: 'US' }
|
||||
)
|
||||
// Cleanly on the grid, not 34.900000000000006 — two spellings of one cell would
|
||||
// group into two pins sitting on top of each other.
|
||||
expect(presenceGeoFromCf({ latitude: '34.8', longitude: '0.1', country: 'gb' })).toEqual({
|
||||
lat: 35,
|
||||
lon: 0,
|
||||
country: 'GB',
|
||||
})
|
||||
// `T1` is Tor, not a country.
|
||||
expect(presenceGeoFromCf({ latitude: '0', longitude: '0', country: 'T1' })?.country).toBe('XX')
|
||||
// No `cf` at all is the ordinary local-dev case, and must not become a pin at (0, 0).
|
||||
expect(presenceGeoFromCf(undefined)).toBeNull()
|
||||
expect(presenceGeoFromCf({ country: 'US' })).toBeNull()
|
||||
})
|
||||
|
||||
it('serves the aggregated docs page with a source per documented service', async () => {
|
||||
const res = await SELF.fetch('https://example.com/docs')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
+22
-1
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { countOnlinePlayers, countOnlinePlayersByLocation } from '@repo/domain/src/presence-db'
|
||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { authUnreachable } from './auth-messages'
|
||||
@@ -97,6 +97,27 @@ const app = new Hono<App>()
|
||||
})
|
||||
})
|
||||
|
||||
// Where those players are, for the globe on the homepage: one pin per populated grid
|
||||
// cell with a head-count, and nothing else. Same open CORS as the head-count above.
|
||||
//
|
||||
// No address ever reaches this worker, let alone the browser. A player's location is
|
||||
// resolved by the EDGE from the IP their own game client's request arrived on, snapped
|
||||
// to a ~55km grid before it is stored, and grouped into counts by the database — so
|
||||
// the finest thing that exists to serve is "n players somewhere in this cell", and
|
||||
// there is no per-player row to leak even if this route were made to return more.
|
||||
//
|
||||
// `players` is everyone online and `located` only those with a pin, because they can
|
||||
// differ (a player the edge can't place, or one whose row predates geo) and a globe
|
||||
// showing eight pins under a headline reading twelve looks broken rather than partial.
|
||||
.get('/server-status/locations', withDefaultCors(), async (c) => {
|
||||
const pins = await countOnlinePlayersByLocation(c.env.DB)
|
||||
return c.json({
|
||||
players: await countOnlinePlayers(c.env.DB),
|
||||
located: pins.reduce((n, pin) => n + pin.players, 0),
|
||||
pins,
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Signup -------------------------------------------------------------
|
||||
|
||||
// Create an account from the website, behind a Turnstile bot check. The check is what
|
||||
|
||||
+11
-2
@@ -30,7 +30,9 @@
|
||||
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
|
||||
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
|
||||
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
|
||||
// routes (`/api/*`, `/docs*`, `/privacy` and `/server-status`).
|
||||
// routes (`/api/*`, `/docs*`, `/privacy` and `/server-status*`) — including SUB-routes:
|
||||
// `/server-status` matches that exact path only, so `/server-status/locations` (the
|
||||
// globe's pins) needed its own pattern or it silently served the homepage instead.
|
||||
// `/docs/scalar.standalone.js` is
|
||||
// deliberately excluded so it's served directly as the static asset it is.
|
||||
//
|
||||
@@ -40,7 +42,14 @@
|
||||
"assets": {
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "single-page-application",
|
||||
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy", "/server-status"]
|
||||
"run_worker_first": [
|
||||
"/api/*",
|
||||
"/docs",
|
||||
"/docs/openapi/*",
|
||||
"/privacy",
|
||||
"/server-status",
|
||||
"/server-status/*"
|
||||
]
|
||||
},
|
||||
// The Turnstile keypair guarding web signup, out of the same account-level Secrets
|
||||
// Store every other worker binds for JWT_SECRET — values live there, never in this
|
||||
|
||||
Reference in New Issue
Block a user