mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
adding more outfits, putting back matchmake/dorm
This commit is contained in:
+16
-3
@@ -1,9 +1,22 @@
|
|||||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
||||||
RECFLARE_DOMAIN=rec.example.com
|
RECFLARE_DOMAIN=rec.example.com
|
||||||
|
|
||||||
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
# Optional per-service subdomain overrides, as a compact JSON object keyed by the
|
||||||
# worker's directory name. Defaults to the directory name when unset.
|
# service's default subdomain (which, for a service backed by a worker, is that
|
||||||
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
|
# 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
|
# Id of the shared `recflare` D1 database (create it manually with
|
||||||
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
||||||
|
|||||||
+10
-3
@@ -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`)
|
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
|
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
|
||||||
`RECFLARE_SUBDOMAINS` (a JSON object, e.g. `'{"playersettings":"settings"}'`). This would be used
|
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
|
||||||
if you wanted to merge two services together.
|
`'{"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:**
|
**Create the storage resources:**
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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
|
`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.
|
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
|
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
|
apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||||
`README.md` under `apps/<name>/` documenting its routes.
|
`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 |
|
| Link | `link` | — | Not yet implemented |
|
||||||
| Lists | `lists` | — | Not yet implemented |
|
| Lists | `lists` | — | Not yet implemented |
|
||||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
| 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) |
|
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
|
||||||
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
|
| PlatformNotifications | `platformnotifications` | — | Not yet implemented |
|
||||||
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
||||||
|
|||||||
@@ -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 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,
|
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||||
// or 404 when there's no such invention.
|
// or 404 when there's no such invention.
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -460,6 +460,17 @@ describe('public endpoints', () => {
|
|||||||
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
|
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 () => {
|
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -2092,6 +2103,7 @@ describe('openapi', () => {
|
|||||||
'GET /api/rooms/v1/filters',
|
'GET /api/rooms/v1/filters',
|
||||||
'GET /api/versioncheck/v4',
|
'GET /api/versioncheck/v4',
|
||||||
'GET /outfits/me',
|
'GET /outfits/me',
|
||||||
|
'GET /outfits/me/saved',
|
||||||
'GET /voice/config',
|
'GET /voice/config',
|
||||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
|
|||||||
@@ -1099,6 +1099,41 @@ const app = new Hono<App>()
|
|||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
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<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(
|
.post(
|
||||||
'/matchmake/dorm',
|
'/matchmake/dorm',
|
||||||
describeRoute({
|
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 player’s dorm gets a distinct global subroom id', async () => {
|
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.
|
// 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.
|
// 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 /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/dorm',
|
'POST /matchmake/dorm',
|
||||||
|
'POST /matchmake/none',
|
||||||
'POST /matchmake/player/{playerId}',
|
'POST /matchmake/player/{playerId}',
|
||||||
'POST /matchmake/room/{roomId}',
|
'POST /matchmake/room/{roomId}',
|
||||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ recflare_load_env
|
|||||||
# custom domain via `--domain`. This keeps the real domain out of versioned files
|
# 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
|
# — 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
|
# 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
|
if [ -z "${RECFLARE_DOMAIN:-}" ]; then
|
||||||
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
echo "error: RECFLARE_DOMAIN is not set — export it or add it to .env (see .env.example)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -143,6 +150,7 @@ wrangler deploy \
|
|||||||
--var NAME:"$NAME" \
|
--var NAME:"$NAME" \
|
||||||
--var SENTRY_RELEASE:"$VERSION" \
|
--var SENTRY_RELEASE:"$VERSION" \
|
||||||
--var DOMAIN:"$DOMAIN" \
|
--var DOMAIN:"$DOMAIN" \
|
||||||
|
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
|
||||||
$EXTRA_VARS \
|
$EXTRA_VARS \
|
||||||
--domain "$HOST" \
|
--domain "$HOST" \
|
||||||
$MINIFY \
|
$MINIFY \
|
||||||
|
|||||||
Reference in New Issue
Block a user