move presence to d1

This commit is contained in:
Devin Zuczek
2026-07-11 12:37:22 -04:00
parent aad184181b
commit fb553c2fd0
16 changed files with 452 additions and 131 deletions
+5 -6
View File
@@ -6,12 +6,11 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// Per-player presence (the room instance they're currently in). Written by
// matchmake/goto, read by the heartbeat, cleared on login — mirrors the
// reference server's HeartbeatDB.
RECFLARE_MATCH_PRESENCE: KVNamespace
// Shared rooms DB (owned by the `rooms` worker). Read-only here to resolve a
// room's real scene/subroom when matchmaking into it.
// Shared `recflare` DB. Resolves room scenes for matchmaking (read), writes a
// player's personal dorm room on first entry, and holds player presence — the
// room instance each player is currently in (written by matchmake/heartbeat,
// read by the heartbeat and the batch `/player` lookup). See @repo/domain's
// presence-db (table owned/migrated by the `rooms` worker).
DB: D1Database
}
+76 -57
View File
@@ -5,23 +5,28 @@ import {
createRoomInstance,
getJoinableInstance,
getOrCreateDormRoom,
getPresence,
getPresences,
getRoomById,
getRoomByName,
getRoomInstancesByRoom,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
setRoomInstanceInProgress,
} from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import type { Room } from '@repo/domain'
import type { Room, StoredPresence } from '@repo/domain'
import type { Context } from 'hono'
import type { App } from './context'
/**
* The matchmaking surface. Rooms and room instances are D1-backed (matchmaking
* finds/creates a `room_instance` row per session); player lookups still fall back
* to default values when nothing is found. Presence lives in the match KV.
* to default values when nothing is found. Presence is D1-backed too (the
* `presence` table; see @repo/domain's presence-db).
*
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/
@@ -72,20 +77,19 @@ type RoomInstance = ReturnType<typeof dormRoomInstance>
/**
* Stored presence for a player — the room instance they matchmade into plus the
* status fields the heartbeat echoes back. Mirrors the reference server's
* HeartbeatDB row.
* status fields the heartbeat echoes back. The generic StoredPresence lives in
* @repo/domain; here it's specialized to the match worker's RoomInstance shape.
*/
interface Presence {
roomInstance: RoomInstance | null
statusVisibility: number
deviceClass: number
vrMovementMode: number
platform: number
appVersion: string
}
type Presence = StoredPresence<RoomInstance>
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
const PRESENCE_TTL = 900
/**
* A heartbeat that changes nothing re-writes presence only once its TTL drops
* within this window of expiring (s), instead of on every beat. So a player who's
* sitting still is refreshed at most once per (PRESENCE_TTL_SECONDS this) rather
* than on every heartbeat — far fewer D1 writes, while still staying comfortably
* ahead of expiry (the client heartbeats many times inside this window).
*/
const PRESENCE_REFRESH_THRESHOLD = 300
/**
* Game build version reported in presence. This is a server-side constant — the
@@ -94,24 +98,11 @@ const PRESENCE_TTL = 900
*/
const GAME_VERSION = '20230302'
const presenceKey = (id: number) => `presence:${id}`
/** Persist the player's presence (room instance + status), refreshing the TTL. */
async function setPresence(c: Context<App>, id: number, presence: Presence): Promise<void> {
await c.env.RECFLARE_MATCH_PRESENCE.put(presenceKey(id), JSON.stringify(presence), {
expirationTtl: PRESENCE_TTL,
})
}
/** Read the player's stored presence, or null when they aren't in a room. */
async function getPresence(c: Context<App>, id: number): Promise<Presence | null> {
return c.env.RECFLARE_MATCH_PRESENCE.get<Presence>(presenceKey(id), 'json')
}
/** Store the room instance the player just matchmade into, preserving status. */
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
const prev = await getPresence(c, id)
await setPresence(c, id, {
const prev = await getPresence<RoomInstance>(c.env.DB, id)
await setPresence(c.env.DB, {
accountId: id,
roomInstance,
statusVisibility: prev?.statusVisibility ?? 0,
deviceClass: prev?.deviceClass ?? 0,
@@ -119,6 +110,15 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
platform: prev?.platform ?? 0,
appVersion: prev?.appVersion || GAME_VERSION,
})
// Keep the destination instance's is_full flag in sync with live presence (the
// player's own presence, just written, is counted). Then re-evaluate the
// instance they left — its head-count dropped — so a full room frees up when
// players move on. Both no-op for the synthetic dorm/orientation instances.
await refreshInstanceFullness(c.env.DB, roomInstance.roomInstanceId)
const leftId = prev?.roomInstance?.roomInstanceId
if (leftId != null && leftId !== roomInstance.roomInstanceId) {
await refreshInstanceFullness(c.env.DB, leftId)
}
}
/**
@@ -330,21 +330,22 @@ const app = new Hono<App>()
.filter((n) => !Number.isNaN(n))
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
const players = await Promise.all(
ids.map(async (playerId) => {
const p = await getPresence(c, playerId)
return {
playerId,
statusVisibility: p?.statusVisibility ?? 0,
deviceClass: p?.deviceClass ?? 0,
vrMovementMode: p?.vrMovementMode ?? 1,
roomInstance: p?.roomInstance ?? null,
isOnline: p?.roomInstance != null,
appVersion: p?.appVersion || GAME_VERSION,
platform: p?.platform ?? 0,
}
})
)
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
// point read per id as the KV store required.
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
const players = ids.map((playerId) => {
const p = presences.get(playerId)
return {
playerId,
statusVisibility: p?.statusVisibility ?? 0,
deviceClass: p?.deviceClass ?? 0,
vrMovementMode: p?.vrMovementMode ?? 1,
roomInstance: p?.roomInstance ?? null,
isOnline: p?.roomInstance != null,
appVersion: p?.appVersion || GAME_VERSION,
platform: p?.platform ?? 0,
}
})
return c.json(players)
})
@@ -367,16 +368,34 @@ const app = new Hono<App>()
// Return the player's stored presence (set by matchmake/goto), mirroring the
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
// fields are merged back and the TTL refreshed so presence stays alive.
const presence = await getPresence(c, id)
// fields are merged back; the row is re-written (refreshing the TTL) only when
// something changed or its TTL is close to lapsing — see below.
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
if (hb.statusVisibility !== undefined) presence.statusVisibility = hb.statusVisibility
if (hb.deviceClass !== undefined) presence.deviceClass = hb.deviceClass
if (hb.vrMovementMode !== undefined) presence.vrMovementMode = hb.vrMovementMode
if (hb.platform !== undefined) presence.platform = hb.platform
if (hb.appVersion) presence.appVersion = hb.appVersion
if (!presence.appVersion) presence.appVersion = GAME_VERSION
await setPresence(c, id, presence)
// Merge the posted status fields, tracking whether any actually changed.
let changed = false
const apply = <K extends keyof Presence>(key: K, value: Presence[K]) => {
if (presence[key] !== value) {
presence[key] = value
changed = true
}
}
if (hb.statusVisibility !== undefined) apply('statusVisibility', hb.statusVisibility)
if (hb.deviceClass !== undefined) apply('deviceClass', hb.deviceClass)
if (hb.vrMovementMode !== undefined) apply('vrMovementMode', hb.vrMovementMode)
if (hb.platform !== undefined) apply('platform', hb.platform)
if (hb.appVersion) apply('appVersion', hb.appVersion)
if (!presence.appVersion) apply('appVersion', GAME_VERSION)
// Extending the TTL means re-writing the row, so skip the write on an
// unchanged heartbeat until the TTL is within PRESENCE_REFRESH_THRESHOLD
// (s) of lapsing — a still player is refreshed periodically rather than on
// every beat. `expiresAt` is epoch seconds (set by setPresence).
const nowSeconds = Math.floor(Date.now() / 1000)
const dueForRefresh = presence.expiresAt - nowSeconds <= PRESENCE_REFRESH_THRESHOLD
if (changed || dueForRefresh) {
await setPresence(c.env.DB, presence)
}
}
return c.json({
@@ -397,10 +416,10 @@ const app = new Hono<App>()
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const sv =
typeof body.statusVisibility === 'string' ? Number.parseInt(body.statusVisibility, 10) : NaN
const presence = await getPresence(c, id)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence && !Number.isNaN(sv)) {
presence.statusVisibility = sv
await setPresence(c, id, presence)
await setPresence(c.env.DB, presence)
}
}
return c.body(null, 200)
@@ -434,7 +453,7 @@ const app = new Hono<App>()
// So: preserve existing presence; only fall back to the offline dorm when the
// player has none (e.g. the title screen before they've entered any room).
if (id !== null) {
const presence = await getPresence(c, id)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence?.roomInstance) {
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
}
+127 -1
View File
@@ -2,7 +2,12 @@ import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain'
import {
countPlayersInInstance,
getRoomInstance,
PRESENCE_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
} from '@repo/domain'
import '../../match.app'
@@ -40,6 +45,14 @@ const TEST_ROOMS = [
CreatorAccountId: 42,
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
},
{
// A single-seat room so one player fills its instance (fullness tests).
RoomId: 5,
Name: 'SoloRoom',
IsDorm: false,
Accessibility: 1,
SubRooms: [{ SubRoomId: 5, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }],
},
]
beforeAll(async () => {
@@ -58,6 +71,8 @@ beforeAll(async () => {
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
@@ -458,6 +473,117 @@ describe('auth-gated endpoints', () => {
})
})
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
// TTL-refresh branch can be exercised deterministically (independent of timing).
const seedPresence = (id: number, expiresAt: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
roomInstance: { roomInstanceId: 1000042, roomId: 1 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: '20230302',
expiresAt,
})
)
.run()
const storedExpiresAt = async (id: number): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(id)
.first<{ data: string }>()
return (JSON.parse(row!.data) as { expiresAt: number }).expiresAt
}
const nowSeconds = () => Math.floor(Date.now() / 1000)
test('heartbeat refreshes presence when its TTL is close to lapsing', async () => {
// TTL about to lapse (well inside the refresh window).
const nearExpiry = nowSeconds() + 10
await seedPresence(700, nearExpiry)
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('700'),
})
// The heartbeat re-wrote the row, pushing expiry ~PRESENCE_TTL_SECONDS ahead.
expect(await storedExpiresAt(700)).toBeGreaterThan(nearExpiry + 60)
})
test('heartbeat skips the write when nothing changed and the TTL is healthy', async () => {
// A distinctive, far-future expiry (outside the refresh window) survives
// untouched — proving the unchanged heartbeat did not re-write the row.
const healthyExpiry = nowSeconds() + 800
await seedPresence(701, healthyExpiry)
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
method: 'POST',
headers: await bearer('701'),
})
expect(await storedExpiresAt(701)).toBe(healthyExpiry)
})
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
// Three players in instance 1000099 — two live, one expired.
const seedInInstance = (id: number, expiresAt: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
roomInstance: { roomInstanceId: 1000099, roomId: 2 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 1,
platform: 0,
appVersion: '20230302',
expiresAt,
})
)
.run()
await seedInInstance(710, nowSeconds() + 800)
await seedInInstance(711, nowSeconds() + 800)
await seedInInstance(712, nowSeconds() - 10) // already expired → not counted
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
})
// Matchmake into a room, returning the resulting instance id.
const matchmakeInto = async (room: string, sub: string): Promise<number> => {
const res = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/${room}`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { roomInstance: { roomInstanceId: number } }
return res.roomInstance.roomInstanceId
}
test('matchmaking flags an instance full once it reaches capacity, and routes the next player elsewhere', async () => {
// SoloRoom (RoomId 5, MaxPlayers 1): one player fills its instance.
const first = await matchmakeInto('5', '820')
expect((await getRoomInstance(env.DB, first))?.isFull).toBe(true)
// A second player can't join the full instance — matchmaking makes a fresh one.
const second = await matchmakeInto('5', '821')
expect(second).not.toBe(first)
expect((await getRoomInstance(env.DB, second))?.isFull).toBe(true)
})
test('matchmaking leaves an instance not full below capacity', async () => {
// RecCenter (RoomId 2, MaxPlayers 12): one player does not fill it.
const instanceId = await matchmakeInto('2', '822')
expect((await getRoomInstance(env.DB, instanceId))?.isFull).toBe(false)
})
test('leaving a full instance clears its full flag', async () => {
// Fill SoloRoom, then the same player matchmakes into RecCenter — the SoloRoom
// instance they left should no longer be full.
const solo = await matchmakeInto('5', '823')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true)
await matchmakeInto('2', '823')
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
})
test('player/login, exclusivelogin and logout all preserve presence', async () => {
const headers = await bearer('9')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
+4 -13
View File
@@ -4,19 +4,10 @@
"main": "src/match.app.ts",
"compatibility_date": "2025-09-20",
"compatibility_flags": ["nodejs_compat"],
// Per-player presence store (room instance the player is currently in). Create
// with `wrangler kv namespace create RECFLARE_MATCH_PRESENCE`, then put the id in
// the root .env under RECFLARE_KV (see .env.example)it is spliced into the
// "local" placeholder below at deploy time.
"kv_namespaces": [
{
"binding": "RECFLARE_MATCH_PRESENCE",
"id": "local"
}
],
// Shared `recflare` DB. Mostly read (room scenes for matchmaking); also writes a
// player's personal dorm room on first dorm entry (see getOrCreateDormRoom). The
// "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time.
// Shared `recflare` DB. Reads room scenes for matchmaking, writes a player's
// personal dorm room on first dorm entry (see getOrCreateDormRoom), and holds
// player presence (the `presence` table — owned/migrated by the `rooms` worker).
// The "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time.
"d1_databases": [
{
"binding": "DB",