From c5b602bbd288012cbcd031dd7204927af4b4d585 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sat, 1 Aug 2026 17:35:38 -0400 Subject: [PATCH] adding more outfits, putting back matchmake/dorm --- .env.example | 19 ++++++++-- DEPLOYING.md | 13 +++++-- SERVICES.md | 8 ++++- apps/api/src/routes/avatar.ts | 26 ++++++++++++++ apps/api/src/test/integration/api.test.ts | 12 +++++++ apps/match/src/match.app.ts | 35 ++++++++++++++++++ apps/match/src/test/integration/api.test.ts | 40 +++++++++++++++++++++ packages/tools/bin/run-wrangler-deploy | 10 +++++- 8 files changed, 155 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 823cb41..24fbc30 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,22 @@ # Base domain all service hosts are derived from, e.g. accounts.. 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. +# {"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. diff --git a/DEPLOYING.md b/DEPLOYING.md index 85ecb22..12a36f8 100644 --- a/DEPLOYING.md +++ b/DEPLOYING.md @@ -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.`. Redeploy `ns` +after changing it — `just deploy -F ns`. **Create the storage resources:** diff --git a/SERVICES.md b/SERVICES.md index 5005c1a..0f9acad 100644 --- a/SERVICES.md +++ b/SERVICES.md @@ -6,6 +6,12 @@ Each is reached at `https://.`. 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//` 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) | diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index fa3ed6a..263cf69 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -327,6 +327,32 @@ export const avatarRoutes = new Hono({ 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 caller’s 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( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 81d60f5..cc3c0c0 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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', diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 63b6e77..f489ae4 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -1099,6 +1099,41 @@ const app = new Hono() 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 caller’s 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(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({ diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 6d48917..ee34200 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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 player’s 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}', diff --git a/packages/tools/bin/run-wrangler-deploy b/packages/tools/bin/run-wrangler-deploy index 5e27297..67dc578 100755 --- a/packages/tools/bin/run-wrangler-deploy +++ b/packages/tools/bin/run-wrangler-deploy @@ -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 \