mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[www] maybe add a fun little globe of players
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
presenceGeoFromCf,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
setLoginContext,
|
||||
@@ -62,7 +63,7 @@ import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { Account, PresenceGeo } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
@@ -154,7 +155,8 @@ const ORIENTATION_INSTANCE_ID = -2
|
||||
async function placeNewPlayerInOrientation(
|
||||
env: App['Bindings'],
|
||||
accountId: number,
|
||||
deviceClass: number
|
||||
deviceClass: number,
|
||||
geo: PresenceGeo | null
|
||||
): Promise<void> {
|
||||
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
|
||||
// live in the room blob), so the Orientation scene resolves the same way match does.
|
||||
@@ -195,6 +197,9 @@ async function placeNewPlayerInOrientation(
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
// The first pin a new player gets — the sign-in that made the account is the only
|
||||
// request we've seen from them, and match's heartbeat refreshes it from there.
|
||||
geo: geo ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -893,7 +898,12 @@ const app = new Hono<App>()
|
||||
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
|
||||
}
|
||||
// Place the new player in Orientation (they don't explicitly matchmake into it).
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
|
||||
await placeNewPlayerInOrientation(
|
||||
c.env,
|
||||
account.accountId,
|
||||
deviceClass,
|
||||
presenceGeoFromCf(c.req.raw.cf)
|
||||
)
|
||||
} else if (grantType === 'refresh_token') {
|
||||
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
|
||||
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
MatchmakingErrorCode,
|
||||
MessageType,
|
||||
MOST_ACTIVE_CLUBHOUSE_LIMIT,
|
||||
presenceGeoFromCf,
|
||||
recordRoomVisit,
|
||||
refreshInstanceFullness,
|
||||
RoomInstanceType,
|
||||
@@ -523,6 +524,10 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
|
||||
// and the heartbeat can keep verifying against it.
|
||||
loginLock: prev?.loginLock,
|
||||
// Where this matchmake came from, coarsened at the edge (see presenceGeoFromCf).
|
||||
// Falls back to the row's last known cell when the request carried no geolocation,
|
||||
// so a player only leaves the globe when they leave the server.
|
||||
geo: presenceGeoFromCf(c.req.raw.cf) ?? prev?.geo,
|
||||
})
|
||||
|
||||
// Count the visit. Every matchmake route funnels through here with the instance the
|
||||
@@ -1285,6 +1290,7 @@ const app = new Hono<App>()
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (presence) {
|
||||
presence.loginLock = loginLock
|
||||
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
|
||||
await setPresence(c.env.DB, presence)
|
||||
} else {
|
||||
// No live presence yet — seed a lobby row (roomInstance null) holding the
|
||||
@@ -1299,6 +1305,7 @@ const app = new Hono<App>()
|
||||
platform: account?.platform ?? 0,
|
||||
appVersion: (await callerVersion(c)) ?? GAME_VERSION,
|
||||
loginLock,
|
||||
geo: presenceGeoFromCf(c.req.raw.cf) ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1574,6 +1581,11 @@ const app = new Hono<App>()
|
||||
const versionChanged = version !== null && presence.appVersion !== version
|
||||
if (versionChanged) presence.appVersion = version
|
||||
|
||||
// The heartbeat is the only call a parked player keeps making, so it's what
|
||||
// keeps their location current — someone who moves house or switches to mobile
|
||||
// data re-pins on the next refresh instead of at their next matchmake.
|
||||
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
|
||||
|
||||
// Otherwise the heartbeat's only side effect is refreshing the TTL, and only
|
||||
// once it's within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is
|
||||
// refreshed periodically rather than re-written on every beat. `expiresAt` is
|
||||
@@ -1611,6 +1623,7 @@ const app = new Hono<App>()
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
if (presence && !Number.isNaN(sv)) {
|
||||
presence.statusVisibility = sv
|
||||
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
|
||||
await setPresence(c.env.DB, presence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+8
@@ -1237,6 +1237,9 @@ importers:
|
||||
'@scalar/api-reference':
|
||||
specifier: 1.63.0
|
||||
version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3)
|
||||
cobe:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
hono:
|
||||
specifier: 4.12.27
|
||||
version: 4.12.27
|
||||
@@ -3307,6 +3310,9 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cobe@2.0.1:
|
||||
resolution: {integrity: sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -6253,6 +6259,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cobe@2.0.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
Reference in New Issue
Block a user