adding more outfits, putting back matchmake/dorm

This commit is contained in:
Devin Zuczek
2026-08-01 17:35:38 -04:00
parent cf05026285
commit c5b602bbd2
8 changed files with 155 additions and 8 deletions
+16 -3
View File
@@ -1,9 +1,22 @@
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
RECFLARE_DOMAIN=rec.example.com
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
# worker's directory name. Defaults to the directory name when unset.
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
# Optional per-service subdomain overrides, as a compact JSON object keyed by the
# service's default subdomain (which, for a service backed by a worker, is that
# worker's directory name). Unlisted services keep their default.
#
# One entry moves both sides: it decides which host `just deploy` puts the worker on
# AND which host the `ns` discovery document advertises to the client, so the two can't
# drift apart. Redeploy `ns` (`just deploy -F ns`) after changing this.
#
# {"playersettings":"settings"} the playersettings worker moves to settings.<domain>
# {"moderation":"api"} Moderation has no worker of its own, so this is a pure
# client-side redirect: it points the client's Moderation
# calls at the api worker, which is where the
# /api/PlayerReporting/… routes actually live
#
# Keep it compact — no spaces. Services are listed in SERVICES.md.
# RECFLARE_SUBDOMAINS='{"moderation":"api"}'
# Id of the shared `recflare` D1 database (create it manually with
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
+10 -3
View File
@@ -75,9 +75,16 @@ cp .env.example .env
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
(Optional) - per-app subdomain overrides come from
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
if you wanted to merge two services together.
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
puts that worker on and which host the `ns` discovery document advertises to the client,
so the two can't drift apart.
This is also how you merge two services together: `'{"moderation":"api"}'` points the
client's Moderation calls at the `api` worker (which is where the `/api/PlayerReporting/…`
routes already live) without deploying anything on `moderation.<domain>`. Redeploy `ns`
after changing it — `just deploy -F ns`.
**Create the storage resources:**
+7 -1
View File
@@ -6,6 +6,12 @@ Each is reached at `https://<subdomain>.<your-domain>`. Services with a worker i
`apps/` are implemented here; the rest are advertised in the endpoints document
but not yet backed by a Worker. Not all services are fully implemented.
The subdomains below are the defaults. Any of them can be redirected from `.env` via
`RECFLARE_SUBDOMAINS`, keyed by the subdomain in this table — which both moves where the
worker deploys and what `ns` advertises. Pointing a service with no worker at one that has
one merges them, e.g. `'{"moderation":"api"}'` sends the client's Moderation calls to the
`api` worker, where the `/api/PlayerReporting/…` routes already live. See `DEPLOYING.md`.
A small `ns` worker itself serves this discovery document at the
apex/`ns` host and isn't listed within it. Each implemented worker has its own
`README.md` under `apps/<name>/` documenting its routes.
@@ -34,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| Link | `link` | — | Not yet implemented |
| Lists | `lists` | — | Not yet implemented |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | — | Not yet implemented |
| Moderation | `moderation` | — | No worker; point it at `api` to serve `/api/PlayerReporting/…` |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
+26
View File
@@ -327,6 +327,32 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
// belong in this list (and in what shape) has not been pinned down, so it answers []
// rather than guessing.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar'],
summary: 'The callers saved outfits',
description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
'list should carry, and in what shape, is not pinned down yet.',
security: AUTHED,
responses: {
200: json(JsonArray, 'An empty list'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
}
)
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
// or 404 when there's no such invention.
.get(
+12
View File
@@ -460,6 +460,17 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401)
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('PUT /outfits/me 400s on an unparseable body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
@@ -2092,6 +2103,7 @@ describe('openapi', () => {
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/v4',
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /voice/config',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/PlayerReporting/v1/deviceId',
+35
View File
@@ -1099,6 +1099,41 @@ const app = new Hono<App>()
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake with no target. The client posts this when it needs an instance but isn't
// going anywhere in particular — at startup, and while sitting in Orientation. It
// answers the instance the player is ALREADY in, so it never warps anyone out of the
// room they're standing in; only a player with no live presence falls back to their
// dorm. Either way presence is re-committed, which refreshes its TTL.
.post(
'/matchmake/none',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake with no target',
description: [
'Answers the instance the caller is already in, rather than sending them anywhere —',
'this is what the client posts at startup and while in Orientation, so forcing a',
'destination here would warp the player out of the room they are standing in. A',
'caller with no live presence (their TTL lapsed, or they have never entered a room)',
'falls back to their personal dorm. Re-commits presence either way, refreshing its',
'TTL.',
].join(' '),
security: AUTHED,
responses: {
200: json(MatchmakeResponse, 'The callers current instance, or their dorm'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const presence = await getPresence<RoomInstance>(c.env.DB, id)
const current = presence?.roomInstance ?? (await playerDormInstance(c, id))
await enterRoom(c, id, current)
return c.json({ errorCode: 0, roomInstance: current })
}
)
.post(
'/matchmake/dorm',
describeRoute({
@@ -676,6 +676,45 @@ describe('auth-gated endpoints', () => {
})
})
test('POST /matchmake/none 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
expect(res.status).toBe(401)
})
test('POST /matchmake/none keeps the caller where they are, else falls back to the dorm', async () => {
const none = async (sub: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/none`, {
method: 'POST',
headers: await bearer(sub),
})
).json()) as { errorCode: number; roomInstance: { roomId: number; roomInstanceId: number } }
// Account 44 has never entered a room → their personal dorm, and a second call is
// idempotent now that presence holds it.
const fresh = await none('44')
expect(fresh.errorCode).toBe(0)
expect(fresh.roomInstance.roomId).toBeGreaterThan(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: fresh.roomInstance.roomId,
roomInstanceId: fresh.roomInstance.roomInstanceId,
})
// Once in a real room, `none` must NOT warp them out of it — that is the whole
// point of the endpoint, since the client posts it while sitting in Orientation.
const entered = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('44'),
})
).json()) as { roomInstance: { roomId: number; roomInstanceId: number } }
expect(entered.roomInstance.roomId).toBe(2)
expect((await none('44')).roomInstance).toMatchObject({
roomId: 2,
roomInstanceId: entered.roomInstance.roomInstanceId,
})
})
test('each players dorm gets a distinct global subroom id', async () => {
// Dorms used to copy the template subroom verbatim, so every dorm carried SubRoomId 1.
// With subrooms minted from the global sequence, each dorm gets its own unique id.
@@ -1453,6 +1492,7 @@ describe('auth-gated endpoints', () => {
'POST /invite',
'POST /matchmake/club/{clubId}',
'POST /matchmake/dorm',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
+9 -1
View File
@@ -16,7 +16,14 @@ recflare_load_env
# custom domain via `--domain`. This keeps the real domain out of versioned files
# — committed wrangler.jsonc has no routes, and the base domain is passed as the
# DOMAIN var at runtime. Per-app subdomain overrides come from RECFLARE_SUBDOMAINS
# (a JSON object, e.g. {"playersettings":"settings"}).
# (a JSON object keyed by default subdomain, e.g. {"playersettings":"settings"}).
#
# The whole object also rides along as the SUBDOMAINS var, because the `ns` worker
# has to advertise the same hosts to the client that we deploy onto here. Keying it
# by default subdomain is what lets one .env entry do both: a worker's directory
# name IS its default subdomain, so the lookup below and the one in ns/endpoints.ts
# read the same key. Entries for services with no worker (e.g. "moderation") are
# client-side redirects only — nothing here matches them.
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
exit 1
@@ -143,6 +150,7 @@ wrangler deploy \
--var NAME:"$NAME" \
--var SENTRY_RELEASE:"$VERSION" \
--var DOMAIN:"$DOMAIN" \
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
$EXTRA_VARS \
--domain "$HOST" \
$MINIFY \