[2025] unstable

This commit is contained in:
Devin Zuczek
2026-08-13 01:05:55 -04:00
committed by devin
parent 551585179a
commit ec52985ef2
32 changed files with 1396 additions and 109 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
@@ -79,9 +79,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 e.g. send `datacollection` calls to `api`.
(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) |
+4
View File
@@ -108,6 +108,10 @@ function toAccountDto(account: Account) {
username: account.username,
displayName: account.displayName,
profileImage: account.profileImage,
// Nothing writes these yet, and rows stored before they existed have neither
// key — always emit them as "" rather than letting them go missing.
bannerImage: account.bannerImage ?? '',
displayEmoji: account.displayEmoji ?? '',
isJunior: account.isJunior,
platforms: account.platforms,
personalPronouns: account.personalPronouns,
+2
View File
@@ -67,6 +67,8 @@ export const AccountDto = z.object({
username: z.string(),
displayName: z.string(),
profileImage: z.string().describe('Avatar object key'),
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
displayEmoji: z.string().describe('Emoji beside the display name — always "" (nothing sets it yet)'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
@@ -164,6 +164,10 @@ describe('auth-gated endpoints', () => {
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
// Nothing sets these yet, but the key has to be present — the client reads
// both off the account DTO.
bannerImage: '',
displayEmoji: '',
})
// juniorState + parentAccountId must be omitted when null, not emitted as
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
+88 -5
View File
@@ -423,6 +423,86 @@ export const CustomAvatarItemsPage = z.object({
TotalResults: z.int(),
})
/**
* One custom-item save — the rebuilt version of a legacy avatar item. This is the
* official shape, recorded for documentation: nothing stores custom items yet, so we
* never actually emit one of these.
*/
export const CustomAvatarItemSave = z.object({
customAvatarItemSaveId: z.int().describe('The saves id'),
customAvatarItemId: z.string().describe('Guid of the custom item this save belongs to'),
unityAssetId: z.string().describe('Guid of the built Unity asset'),
createdAt: z.string().describe('ISO 8601 timestamp'),
thumbnailFileName: z.string(),
additionalConfiguration: z.string(),
unityAsset: z.string(),
unityAssetHash: z.string(),
})
/**
* The custom-item saves that replace a set of legacy avatar items, keyed by the legacy
* item's `AvatarItemDesc`. Nothing stores custom items yet, so the map is always empty —
* the value shape is documented rather than served.
*/
export const LegacyAvatarItemSaves = z.object({
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
})
/**
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
* account form, where every field that would carry an outfit is null/empty and
* `DataVersion` is 9.
*/
export const OutfitsMeResponse = z.object({
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.string().nullable(),
HairColor: z.string().nullable(),
}),
Selections: JsonArray,
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
CustomizationSettings: z
.string()
.nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
ThumbnailFileName: z.string().nullable(),
Name: z.string().nullable(),
Accessibility: z.int(),
Slot: z.int().describe('0 — the outfit being worn'),
})
/**
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
* `SelectionsV2` and `CustomizationSettings` are whole documents encoded as strings, and
* `FaceFeatures` likewise. Note the two formats overlap: `LegacyData` carries the old
* flat descriptors while `CustomizationSettings` carries the same outfit in the new
* structured form, and the client sends both. `Selections` arrives empty — the actual
* selections are inside those strings.
*/
export const OutfitsMeRequest = z.object({
DataVersion: z.int().describe('The clients outfit format version (2 in observed saves)'),
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
FaceFeatures: z.string().nullable().describe('JSON-in-a-string'),
SkinColor: z.string().nullable(),
HairColor: z.string().nullable(),
}),
CustomizationSettings: z
.string()
.nullable()
.describe('JSON-in-a-string: the same outfit in the newer structured form'),
Selections: JsonArray.describe('Empty in observed saves'),
Slot: z.int(),
Name: z.string().nullable(),
Accessibility: z.int(),
ThumbnailFileName: z.string().nullable(),
})
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
@@ -588,13 +668,16 @@ export const PlayerEventsPage = z.object({
// ---- Moderation ------------------------------------------------------------
/**
* `GET /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet). `ReportCategory` is -1 (no category) rather than 0,
* which is a real category; `Message` is null, not an empty string — the client
* distinguishes "no message" from a blank one.
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet), mirroring the reference server's stub
* `ReturnModerationBlockDetails()`. `ReportCategory` is `Unknown` (-1) rather than 0,
* which is a real category, and `Message` is null — the client distinguishes "no
* message" from a blank one, so we send null where the reference sends an empty string.
* `IsVoiceModAutoban`/`TimeoutStartedAt` are on the DTO but unset by that stub, so
* they carry their C# defaults (false / null).
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = no category (0 is a real one)'),
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
Duration: z.int(),
GameSessionId: z.int(),
IsBan: z.boolean(),
+141
View File
@@ -2,9 +2,12 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
inventionDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
setOutfit,
} from '@repo/domain'
import { authedId, unauthorized } from '../http'
@@ -46,6 +49,9 @@ import {
json,
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
SaveInventionRequest,
SetTagsRequest,
@@ -234,6 +240,141 @@ export const avatarRoutes = new Hono<App>({ strict: false })
(c) => c.json({ Results: [], TotalResults: 0 })
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
// can render the custom version instead. Nothing stores custom items yet, so nothing
// has a save — an empty list means "use the legacy items as-is".
.post(
'/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
describeRoute({
tags: ['Avatar'],
summary: 'Custom-item saves for legacy avatar items',
description:
'Given a set of legacy avatar items, the custom-item saves that replace them, keyed ' +
'by the legacy items `AvatarItemDesc`. Nothing stores custom items yet, so the map ' +
'is always empty — which the client reads as “render the legacy items as-is”. The ' +
'request body is ignored.\n\n' +
'The value shape is the official one, recorded here for documentation; we never ' +
'emit one until custom items are stored.',
responses: { 200: json(LegacyAvatarItemSaves, 'An empty map') },
}),
(c) => c.json({ customAvatarItemSavesByAvatarItemDesc: {} })
)
// The newer outfit read, on a bare (un-prefixed) path. Auth-gated. The outfit the
// player is wearing is slot 0 of the shared `outfit` table (the same table the `econ`
// worker's saved-outfit slots live in); a player who has never saved gets the
// brand-new-account envelope instead.
.get(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'The callers outfit',
description:
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
'handed back exactly as it was saved, since the payloads heavy fields are the ' +
'clients own JSON-in-a-string documents.\n\n' +
'A player who has never saved gets the brand-new-account envelope: all-null ' +
'`LegacyData`, no `Selections`, `DataVersion` 9.',
security: AUTHED,
responses: {
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const outfit = await getOutfit(c.env.DB, id, CURRENT_OUTFIT_SLOT)
if (outfit !== null) return c.json(outfit)
return c.json({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
})
}
)
// Saving an outfit through the same bare path — into the slot the body names, which
// is slot 0 for the outfit being worn. Stored verbatim: the heavy fields are the
// client's own JSON-in-a-string documents, and re-encoding risks changing a payload
// it has to parse back. Answers the saved outfit, which is what the client re-renders
// from.
.put(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
summary: 'Save the callers outfit',
description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
'outfit worn, which is what the GET reads. Re-saving a slot overwrites it.\n\n' +
'The payload is stored verbatim and answered back: its heavy fields (`SelectionsV2`, ' +
'`FaceFeatures`, `CustomizationSettings`) are whole JSON documents encoded as ' +
'strings by the clients own serializer, so nothing here parses or re-encodes them.',
security: AUTHED,
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
responses: {
200: json(OutfitsMeRequest, 'The outfit as stored'),
400: json(ErrorResponse, 'Unparseable body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
// The client sends `Slot`; a body without one saves the worn outfit.
const outfit = {
...body,
Slot: typeof body.Slot === 'number' ? body.Slot : CURRENT_OUTFIT_SLOT,
}
await setOutfit(c.env.DB, id, outfit)
return c.json(outfit)
}
)
// 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(
+54 -10
View File
@@ -61,11 +61,16 @@ const asFloat = (v: string | undefined): number | null => {
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer.
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
// `Message` is null, not an empty string — the client distinguishes "no message"
// from a blank one.
.get(
// but this endpoint is not wired to them, so it's always the "not blocked" answer
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not the empty string that stub sends — the client distinguishes
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
// DTO but left unset there, so they go out with their C# defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
['GET', 'POST'],
'/api/PlayerReporting/v1/moderationBlockDetails',
describeRoute({
tags: ['Moderation'],
@@ -73,11 +78,13 @@ export const moderationRoutes = new Hono<App>({ strict: false })
description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer. Two details matter ' +
'to the client: ' +
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
'`Message` is null rather than an empty string — the client distinguishes “no ' +
'message” from a blank one.',
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference servers stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `Message` is null rather than the empty string that stub ' +
'sends — the client distinguishes “no message” from a blank one. ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
}),
(c) =>
@@ -105,6 +112,43 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
) // TODO: hydrate from JSON/vtkreasons.json
// The client asking whether IT should run its referee moderation — the in-client
// review flow a player with referee standing gets shown. Deliberately `false` for
// everyone: this is an archival server, and the referee program is one of the live
// moderation systems it does not run. Answering true would put the client into a flow
// with no cases behind it. A POST despite being a pure read, which is how the client
// asks.
.post(
'/api/PlayerReporting/v1/referee',
describeRoute({
tags: ['Moderation'],
summary: 'Whether the caller is a referee',
description:
'A bare JSON `false` — no envelope. The game client asks this to decide whether to ' +
'run its referee moderation flow. Always false: the referee program is switched ' +
'off here rather than unimplemented, since this server is archival.',
responses: { 200: json(BareBoolean, 'Always `false` — the program is off') },
}),
(c) => c.json(false)
)
// The referee's own case files — the reviews assigned to them. Empty for the same
// reason the flag above is false: the program is off, so no case is ever assigned. A
// caller reaching this at all has gone past that flag, so the empty list is a second
// line of defence rather than the normal path. A GET, unlike its POSTing neighbours
// in this flow.
.get(
'/api/referee/files',
describeRoute({
tags: ['Moderation'],
summary: 'Referee case files',
description:
'The moderation cases assigned to the caller as a referee. Always empty — the ' +
'referee program is switched off here (see `/api/PlayerReporting/v1/referee`), so ' +
'nothing is ever assigned.',
responses: { 200: json(JsonArray, 'An empty list — no cases are ever assigned') },
}),
(c) => c.json([])
)
.post(
'/api/PlayerReporting/v1/hile',
describeRoute({
+171 -16
View File
@@ -14,6 +14,7 @@ import {
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
@@ -108,6 +109,9 @@ beforeAll(async () => {
// Relationships table (owned by the api worker) — friendship endpoints use it.
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Inventions table (owned by the api worker) — invention save/mine use it.
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -269,23 +273,45 @@ describe('public endpoints', () => {
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
})
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
expect(res.status).toBe(200)
// ReportCategory -1 = no category (0 is a real one), and Message is null.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
// The client POSTs this with no body, despite it being a pure read; the route answers
// GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
async (method) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method }
)
expect(res.status).toBe(200)
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
}
)
test('POST /api/PlayerReporting/v1/referee says the caller is not one', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/referee`, {
method: 'POST',
})
expect(res.status).toBe(200)
// A bare boolean, not an envelope or a list.
expect(await res.json()).toBe(false)
})
test('GET /api/referee/files has no cases', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/referee/files`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// Unauthenticated by design — the client posts this before it has an account, so
@@ -462,6 +488,128 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems returns an empty map', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ AvatarItemIds: [1, 2, 3] }),
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ customAvatarItemSavesByAvatarItemDesc: {} })
})
test('GET /outfits/me 401s without a token, serves the empty envelope for a new player', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`)
expect(anon.status).toBe(401)
// Account 77 never saves an outfit, so it keeps getting the new-account envelope.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
})
})
test('PUT /outfits/me saves into slot 0; GET reads it back verbatim', async () => {
// The client's own payload, trimmed to one selection: the point is that the heavy
// JSON-in-a-string fields survive the round trip as strings, unparsed.
const outfit = {
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2:
'{"selections":[{"PrefabGuid":"193a3bf9-abc0-4d78-8d63-92046908b1c5","CombinationGuid":"","BodyPart":0}]}',
FaceFeatures: '{"ver":7,"eyeId":"Aeu0yxJXG0qCOLZW5Tcu7A","hideEars":false}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: 0,
Name: null,
Accessibility: 1,
ThumbnailFileName: null,
}
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual(outfit)
// The read serves it back byte-for-byte — the JSON-in-a-string fields are still
// strings, not re-encoded objects.
const read = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await read.json()).toEqual(outfit)
// Re-saving overwrites slot 0 rather than adding a second row.
const changed = { ...outfit, LegacyData: { ...outfit.LegacyData, SkinColor: 'changed' } }
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify(changed),
})
const reread = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
expect(await reread.json()).toEqual(changed)
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM outfit WHERE account_id = 42'
).first<{ n: number }>()
expect(rows?.n).toBe(1)
// A save naming another slot does not touch what the caller is wearing.
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({ ...changed, Slot: 3, Name: 'slot three' }),
})
const worn = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer() })
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',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: 'not json',
})
expect(res.status).toBe(400)
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/rooms/v1/filters`)
expect(res.status).toBe(200)
@@ -3650,6 +3798,7 @@ describe('openapi', () => {
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/quickPlay/v1/getandclear',
'GET /api/referee/files',
'GET /api/relationships/mutualfriends',
'GET /api/relationships/v1/favorite',
'GET /api/relationships/v1/ignore',
@@ -3667,11 +3816,16 @@ describe('openapi', () => {
'GET /api/rooms/v1/filters',
'GET /api/versioncheck/islandedversions',
'GET /api/versioncheck/v4',
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /voice/config',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v3/create',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
@@ -3705,6 +3859,7 @@ describe('openapi', () => {
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
'PUT /outfits/me',
])
// Every operation carries a summary — an undescribed one renders as a bare path.
+2 -2
View File
@@ -9,7 +9,7 @@
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "gay",
"ImageName": "tip.jpg",
"PlatformMask": 175,
"CreatedAt": "2019-02-28T18:27:25Z"
},
@@ -23,7 +23,7 @@
"Visibility": 0,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "gay",
"ImageName": "tip.jpg",
"PlatformMask": 167,
"CreatedAt": "2019-02-28T18:15:33Z"
},
+93 -15
View File
@@ -7,11 +7,13 @@ import {
consumeGift,
createGift,
getGift,
getOutfits,
getPendingGifts,
grantInvention,
levelReward,
levelsReached,
ownsInvention,
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -29,6 +31,7 @@ import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
@@ -54,9 +57,10 @@ import {
grantConsumable,
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem } from './inventory-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
AUTHED,
AvatarItemV4Dto,
AvatarV2Dto,
BalanceEntry,
BuyInventionResponse,
@@ -64,6 +68,9 @@ import {
BuyItemResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
ChecklistEntry,
CompleteChecklistRequest,
ConsumeConsumableRequest,
ConsumeEnvelope,
ConsumeGiftRequest,
@@ -85,11 +92,10 @@ import {
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { getOutfits, setOutfit } from './outfit-db'
import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
@@ -99,7 +105,6 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { Outfit } from './outfit-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -1160,6 +1165,22 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
}
}
/**
* The default NUX checklist for a brand-new account. `Objective` is an `ObjectiveType`
* ordinal (from the client's `ProgressionManager`) that the client matches its own
* progress events against — the names below are what those ordinals mean.
*/
const DEFAULT_CHECKLIST = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 }, // SaveOutfitSlot
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 }, // VisitACustomRoom
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 }, // AddAFriend
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 }, // GoToRecCenter
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 }, // CheerAPlayer
]
/** The `UpdateResponse` context a checklist reward is reported under. */
const CHECKLIST_REWARD_CONTEXT = 303
/**
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
@@ -1201,11 +1222,12 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(defaultAvatarItems)
)
// Default base avatar items — empty stub for now. No auth.
// The base items UGC clothing is built on top of — served from bundled static JSON,
// separate from the `defaultunlocked` catalog. No auth.
.get(
'/api/avatar/v1/defaultbaseavataritems',
listRoute('Default base avatar items', 'Empty stub for now'),
(c) => c.json([])
listRoute('Default base avatar items', 'The bundled base items UGC clothing builds on'),
(c) => c.json(defaultBaseAvatarItems)
)
// The player's avatar items — the items they've bought (from `buyItem`, stored in
@@ -1219,10 +1241,12 @@ const app = new Hono<App>({ strict: false })
description: [
'The items the player has bought (from buyItem, in the inventory table) prepended',
'to the default catalog. A player who has bought nothing gets just the catalog.',
'Both sources are projected into the camelCase v4 DTO — the sibling item endpoints',
'(`defaultunlocked`, `defaultbaseavataritems`) serve their records raw instead.',
].join(' '),
security: AUTHED,
responses: {
200: json(JsonArray, 'Owned items followed by the default catalog'),
200: json(AvatarItemV4Dto.array(), 'Owned items followed by the default catalog'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1230,7 +1254,7 @@ const app = new Hono<App>({ strict: false })
const id = await authedId(c)
if (id === null) return unauthorized(c)
const owned = await getInventory(c.env.DB, id)
return c.json([...owned, ...defaultAvatarItems])
return c.json([...owned, ...defaultAvatarItems].map(toAvatarItemV4))
}
)
@@ -1376,15 +1400,69 @@ const app = new Hono<App>({ strict: false })
}
)
// NUX checklist — the client fetches this on the econ host during load. []
// with no DB. A 404 here can abort the load orchestration before matchmake.
.get(
'/api/checklist/v1/current',
listRoute('NUX checklist', 'The new-user checklist; [] for now. A 404 can abort load.', true),
// NUX checklist — the client fetches this on the econ host during load, on either
// version path. A 404 here can abort the load orchestration before matchmake. We
// serve the default brand-new-account list to everyone: nothing records per-player
// checklist progress yet, so it never shrinks as steps are done.
.on(
'GET',
['/api/checklist/v1/current', '/api/checklist/v2/current'],
describeRoute({
tags: ['Econ'],
summary: 'NUX checklist',
description:
'The new-user checklist, as the default brand-new-account list — nothing records ' +
'per-player progress yet, so the same rows come back however much the player has ' +
'done. `Objective` is an `ObjectiveType` ordinal the client matches its own ' +
'progress events against. v1 and v2 serve the same list.',
security: AUTHED,
responses: {
200: json(ChecklistEntry.array(), 'The checklist rows, in `Order`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
return c.json(DEFAULT_CHECKLIST)
}
)
// Mark a checklist row done. [Authorize]. Stubbed: there is no objective-progress
// table to record the completion in, and no reward ledger to make the 25-token grant
// once-only — without one, re-posting the same row would mint tokens indefinitely, so
// we grant nothing and report a change of 0. The envelope is still the balance-update
// shape the client parses, so the flow completes instead of erroring.
.on(
'POST',
['/api/checklist/v1/complete', '/api/checklist/v2/complete'],
describeRoute({
tags: ['Econ'],
summary: 'Complete a checklist row (stub)',
description:
'Marks a NUX checklist row done. Stubbed: nothing records the completion (no ' +
'objective-progress table) and nothing is granted — a reward is worth 25 XP and 25 ' +
'tokens, but making that once-only needs a ledger we do not have, and without one ' +
're-posting the same row would mint tokens indefinitely. The response is still the ' +
'balance-update envelope, with `Balance` (the change) 0. v1 and v2 behave alike.',
security: AUTHED,
requestBody: jsonBody(CompleteChecklistRequest, 'Which row was completed — `{ ItemIndex }`'),
responses: {
200: json(ChecklistCompleteResponse, 'The balance-update envelope, granting nothing'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The body names the row (`{ ItemIndex: 1 }`, or `Id` as a fallback) — read only
// once there is somewhere to record it.
return c.json({
BalanceUpdates: [{ UpdateResponse: CHECKLIST_REWARD_CONTEXT, Data: [] }],
Balance: 0,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceType: -2,
})
}
)
+37
View File
@@ -40,6 +40,43 @@ export interface AvatarItem extends Record<string, unknown> {
Rarity: number
}
/**
* The camelCase DTO `GET /api/avatar/v4/items` serves. Distinct from the PascalCase
* `AvatarItem` we store and from what the sibling item endpoints (`defaultunlocked`,
* `defaultbaseavataritems`) serve — those hand back their stored/bundled records raw.
*/
export interface AvatarItemV4 {
avatarItemId: number
avatarItemDesc: string
friendlyName: string
tooltip: string
tagList: string
avatarItemType: number
rarity: number
isBaseAvatarItem: boolean
}
/**
* Project a stored or bundled avatar item into the v4 DTO. Neither source carries an
* `AvatarItemId`, a `TagList` or an `IsBaseAvatarItem` flag — the storefront gift-drops
* we grant from have none and the default catalog has none either — so those default to
* 0 / "" / false rather than being invented.
*/
export function toAvatarItemV4(item: Record<string, unknown>): AvatarItemV4 {
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
return {
avatarItemId: num(item.AvatarItemId),
avatarItemDesc: str(item.AvatarItemDesc),
friendlyName: str(item.FriendlyName),
tooltip: str(item.Tooltip),
tagList: str(item.TagList),
avatarItemType: num(item.AvatarItemType),
rarity: num(item.Rarity),
isBaseAvatarItem: item.IsBaseAvatarItem === true,
}
}
/**
* Grant an item into a player's inventory. Upserts on (account_id, avatar_item_desc):
* owning an item is boolean, so re-buying it refreshes the stored DTO rather than
+50
View File
@@ -130,6 +130,56 @@ export const SubscriptionDto = z.object({
ModifiedAt: z.string(),
})
/**
* One item as `GET /api/avatar/v4/items` serves it — camelCase, unlike the PascalCase
* records the sibling item endpoints hand back. `avatarItemId` is 0 and `tagList` empty
* for every item we have: neither the default catalog nor a storefront gift-drop carries
* them.
*/
export const AvatarItemV4Dto = z.object({
avatarItemId: z.int(),
avatarItemDesc: z.string().describe('The comma-delimited item descriptor, commas and all'),
friendlyName: z.string(),
tooltip: z.string(),
tagList: z.string(),
avatarItemType: z.int(),
rarity: z.int(),
isBaseAvatarItem: z.boolean(),
})
/**
* `POST /api/checklist/v1|v2/complete` JSON body — which checklist row was finished.
* The client posts just `{ "ItemIndex": 1 }`; `Id` is the fallback key read when
* `ItemIndex` is absent or 0.
*/
export const CompleteChecklistRequest = z.object({
ItemIndex: z.int().describe('The rows index — what the client actually sends'),
Id: z.int().optional().describe('Fallback row id, read when ItemIndex is absent or 0'),
})
/**
* `POST /api/checklist/v1|v2/complete` — the balance-update envelope, the same shape
* buyItem answers with. `Balance` is the CHANGE applied, so a stubbed (ungranted)
* completion reports 0. `UpdateResponse` 303 is the checklist-reward context.
*/
export const ChecklistCompleteResponse = z.object({
BalanceUpdates: z.array(z.object({ UpdateResponse: z.int(), Data: z.array(JsonObject) })),
Balance: z.int().describe('The change applied — 0 while completion is stubbed'),
CurrencyType: z.int(),
BalanceType: z.int().describe('-2 = account-wide'),
})
/**
* One row of the new-user checklist (`GET /api/checklist/v1|v2/current`). `Objective` is
* an `ObjectiveType` ordinal the client matches its own progress events against.
*/
export const ChecklistEntry = z.object({
Order: z.int().describe('Position in the list, from 0'),
Objective: z.int().describe('ObjectiveType ordinal, e.g. 38 = SaveOutfitSlot'),
Count: z.int().describe('How many times the objective must happen'),
CreditAmount: z.int().describe('Tokens awarded on completion'),
})
/**
* `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}`
* when they have none (which is everyone without the `developer` role). `{}` rather than a
+97 -30
View File
@@ -8,6 +8,7 @@ import {
getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
@@ -33,7 +34,6 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { Env } from '../../context'
@@ -195,10 +195,15 @@ describe('econ endpoints', () => {
expect(body[0]).toHaveProperty('AvatarItemDesc')
})
test('GET /api/avatar/v1/defaultbaseavataritems is an empty stub (no auth)', async () => {
test('GET /api/avatar/v1/defaultbaseavataritems returns the base items (no auth)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/defaultbaseavataritems`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.map((i) => i.AvatarItemId)).toEqual([2184, 2918])
// The client keys these off IsBaseAvatarItem, and the trailing comma in the desc
// is part of the item descriptor — both are served verbatim.
expect(body.every((i) => i.IsBaseAvatarItem === true)).toBe(true)
expect(body[0]?.AvatarItemDesc).toBe('c5d70cb4-71dd-4fe4-b719-34fe2073c611,')
})
test('GET /api/avatar/v4/items 401s without a token', async () => {
@@ -206,16 +211,34 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/avatar/v4/items returns the item catalog with a valid token', async () => {
test('GET /api/avatar/v4/items serves the catalog in the camelCase v4 shape', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
const body = (await res.json()) as unknown[]
expect(Array.isArray(body)).toBe(true)
const body = (await res.json()) as Array<Record<string, unknown>>
expect(body.length).toBeGreaterThan(0)
expect(body[0]).toHaveProperty('AvatarItemDesc')
expect(body[0]).toHaveProperty('FriendlyName')
// Every key of the DTO is present on every item, and nothing PascalCase leaks
// through from the stored/bundled records.
for (const item of body) {
expect(Object.keys(item).sort()).toEqual([
'avatarItemDesc',
'avatarItemId',
'avatarItemType',
'friendlyName',
'isBaseAvatarItem',
'rarity',
'tagList',
'tooltip',
])
}
expect(typeof body[0]?.avatarItemDesc).toBe('string')
expect(typeof body[0]?.friendlyName).toBe('string')
// The catalog carries no ids, tags or base flag — those default rather than
// being invented.
expect(body[0]?.avatarItemId).toBe(0)
expect(body[0]?.tagList).toBe('')
expect(body[0]?.isBaseAvatarItem).toBe(false)
})
test('GET /api/avatar/v2 401s without a token', async () => {
@@ -397,14 +420,53 @@ describe('econ endpoints', () => {
expect(body.isCompleted).toBe(false)
})
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, {
headers: await bearer(),
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
const expected = [
{ Order: 0, Objective: 38, Count: 1, CreditAmount: 25 },
{ Order: 1, Objective: 32, Count: 1, CreditAmount: 25 },
{ Order: 2, Objective: 2, Count: 1, CreditAmount: 25 },
{ Order: 3, Objective: 30, Count: 1, CreditAmount: 25 },
{ Order: 4, Objective: 6, Count: 1, CreditAmount: 25 },
]
// Both version paths are live and serve the same list.
for (const path of ['/api/checklist/v1/current', '/api/checklist/v2/current']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`)
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, { headers: await bearer() })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(expected)
}
})
test('POST /api/checklist/v1|v2/complete 401s without a token, grants nothing with one', async () => {
for (const path of ['/api/checklist/v1/complete', '/api/checklist/v2/complete']) {
const anon = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(anon.status).toBe(401)
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer('33')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ItemIndex: 1 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
BalanceUpdates: [{ UpdateResponse: 303, Data: [] }],
Balance: 0,
CurrencyType: 2,
BalanceType: -2,
})
}
// Stubbed, so completing rows does not move the balance — re-posting cannot farm
// tokens, and the checklist still lists every row.
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('33'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
test('GET /api/itemWishlists/v1/wishlist/me 401s without a token, returns [] with one', async () => {
@@ -816,9 +878,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
expect(list[0].FriendlyName).toBe('Bowtie (White)')
expect(list[0].AvatarItemDesc).toBe(gift.AvatarItemDesc)
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
expect(list[0].friendlyName).toBe('Bowtie (White)')
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -899,8 +961,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('25'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Supreme Pizza')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Supreme Pizza')).toBe(true)
// Buying it again stacks: a second instance, count summed to 2.
expect((await buy()).status).toBe(200)
@@ -966,8 +1028,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
@@ -1066,8 +1128,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('23'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Bowtie (White)')).toBe(true)
})
/**
@@ -1263,8 +1325,8 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ FriendlyName: string }>
expect(list.some((i) => i.FriendlyName === 'Bowtie (White)')).toBe(true)
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
@@ -1670,9 +1732,10 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('76'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
expect(owned.map((i) => i.avatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
}
// A second box can't roll the same prize: "an item that you don't have" excludes what
@@ -1882,8 +1945,9 @@ describe('econ endpoints', () => {
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('82'),
})
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
@@ -2028,6 +2092,7 @@ describe('econ endpoints', () => {
'GET /api/avatar/v4/items',
'GET /api/challenge/v2/getCurrent',
'GET /api/checklist/v1/current',
'GET /api/checklist/v2/current',
'GET /api/consumables/v2/getUnlocked',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gamerewards/v1/pending',
@@ -2051,6 +2116,8 @@ describe('econ endpoints', () => {
'POST /api/avatar/v3/saved/set',
'POST /api/avatar/v4/saved/set',
'POST /api/challenge/v2/updateProgress',
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request',
'POST /api/objectives/v1/cleargroup',
@@ -0,0 +1,28 @@
[
{
"AvatarItemDesc": "c5d70cb4-71dd-4fe4-b719-34fe2073c611,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCTee_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2184,
"IsBaseAvatarItem": true,
"CreatedAt": "2022-04-19T23:40:30.807Z",
"ThumbnailImage": "KXfytDhXzES2yco-rwqDSA.png"
},
{
"AvatarItemDesc": "95a519de-f2cb-429c-b014-508477f20d42,",
"AvatarItemType": 0,
"PlatformMask": -1,
"FriendlyName": "(UGCPulloverHoodie_Shirt) ",
"Tooltip": "",
"Rarity": -1,
"TagList": "",
"AvatarItemId": 2918,
"IsBaseAvatarItem": true,
"CreatedAt": "2023-04-07T17:07:07.04Z",
"ThumbnailImage": "m4UIuZjNzEWsCP1gpZBgjg.png"
}
]
+184 -1
View File
@@ -37,7 +37,7 @@ import {
subRoomDataBlob,
} from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { generatePhotonAuthToken, validateAndGetAccountId } from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
@@ -53,6 +53,7 @@ import {
AUTHED,
AvoidJuniorsRequest,
AvoidJuniorsResponse,
ConnectionInfoResponse,
EMPTY_OK,
ExclusiveLoginResponse,
form,
@@ -65,6 +66,7 @@ import {
MatchmakeRoomRequest,
NotifyDisconnectRequest,
PlayerDto,
QosRegion,
RoomInstanceDto,
RoomInstanceSummaryDto,
StatusVisibilityRequest,
@@ -102,6 +104,54 @@ const NULL_CONNECTION_INFO = {
experiments: null,
} as const
/**
* The Photon applications the client connects to (`GET /player/connection-info`).
* Temporary placeholders — move them to wrangler vars before they need to differ per
* environment. `photonRegion` matches the value `roomInstanceFromRoom` stamps on every
* instance, so the two can't disagree ('us' resolves to us-east1 for QoS).
*/
const PHOTON_APPS = {
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
photonRegion: 'us',
} as const
/**
* Networking feature flags the client reads off its connection info. Verbatim from
* the reference server — the client changes how it replicates based on these, so they
* are not free to tune. The load-bearing one is `shouldUseGameServerNetworking`:
* true makes the client connect to a local game server (127.0.0.1:7777) instead of
* Photon, which is not what recflare runs.
*/
const PHOTON_EXPERIMENTS = {
networkTransformSyncInterval: 10.0,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
shouldUseGameServerNetworking: false,
} as const
/**
* The regions the client probes for latency (`GET /player/qos`), reporting the results
* back through `PUT /player/photonregionpings`. Rec Room's own QoS endpoints, served
* verbatim: recflare doesn't run probe servers, and the client only uses the timings to
* rank regions — a ranking it can't act on here, since `PHOTON_APPS.photonRegion` pins
* every session to one region regardless. `address` is `host:port`, not a URL.
*/
const QOS_REGIONS = [
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
] as const
/**
* A player's presence as the client reads it (`/player`, `/player/heartbeat`).
* `isOnline` means "has a live presence row" — presence rows expire, so a player who
@@ -1586,6 +1636,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({
@@ -1613,6 +1698,104 @@ const app = new Hono<App>()
}
)
// The realtime credentials the caller should connect with: a freshly minted Photon
// auth token, the Photon applications, and the Photon room they belong in. That last
// one comes from the caller's own presence — the instance matchmaking put them in —
// so it's the same name every other player in that instance is given. The reference
// reads presence and nothing else; we fall back to looking the `roomInstanceId` query
// param up when presence has no room (it expires on a TTL, and the client sometimes
// asks before matchmaking has landed), and to an empty string when neither resolves.
.get(
'/player/connection-info',
describeRoute({
tags: ['Presence'],
summary: 'Photon connection info',
description: [
'The realtime (Photon) credentials the caller should connect with, in a',
'`{ success, value, error }` envelope: a freshly minted `photonAuthToken`, the',
'Photon application ids, and the `photonRoomId` of the instance the caller is in',
'(from their presence, falling back to the `roomInstanceId` query param). There is',
'no separate voice server, so the voice fields are null. `experiments` carries the',
'clients networking flags.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'roomInstanceId',
in: 'query',
required: false,
description: 'The instance being connected to; used only when presence has no room',
schema: { type: 'string' },
},
],
responses: {
200: json(ConnectionInfoResponse, 'The Photon credentials, room, and experiment flags'),
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)
// Presence first (it's the instance the player is actually in); the query param
// only stands in when there's no live presence to read.
let photonRoomId = presence?.roomInstance?.photonRoomId ?? ''
if (!photonRoomId) {
const requested = Number.parseInt(c.req.query('roomInstanceId') ?? '', 10)
if (!Number.isNaN(requested)) {
photonRoomId = (await getRoomInstance(c.env.DB, requested))?.photonRoomId ?? ''
}
}
// Identifies the player to Photon. Signed with the shared JWT secret; the token's
// `aud` is the realtime app it's for. Nothing verifies it while Photon is
// self-hosted, so it's identifying rather than authorizing.
const photonAuthToken = await generatePhotonAuthToken(
id,
{
platformId: (await getAccount(c.env.DB, id))?.platformId ?? '',
platform: presence?.platform ?? 0,
deviceClass: presence?.deviceClass ?? 0,
audience: PHOTON_APPS.photonRealtimeAppId,
},
await c.env.JWT_SECRET.get()
)
return c.json({
success: true,
value: {
photonAuthToken,
...PHOTON_APPS,
photonRoomId,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: PHOTON_EXPERIMENTS,
},
error: null,
})
}
)
// The regions to probe, which the two ping-report routes below are the other half of.
// Unauthenticated: it's a fixed public list, and the client fetches it early. A bare
// array — no `{ success, value, error }` envelope.
.get(
'/player/qos',
describeRoute({
tags: ['Presence'],
summary: 'QoS probe targets',
description: [
'The regions the client pings to measure latency, reporting the results back through',
'`PUT /player/photonregionpings`. Rec Rooms own probe endpoints, served verbatim —',
'recflare runs none of its own, and the resulting ranking is unused anyway: every',
'session is pinned to the one region `/player/connection-info` hands out.',
].join(' '),
responses: { 200: json(QosRegion.array(), 'The regions to probe, as `host:port`') },
}),
(c) => c.json(QOS_REGIONS)
)
// Region ping reports — accept-and-ack (the reference returns Ok()).
.put(
'/player/photonregionpings',
+59
View File
@@ -170,6 +170,65 @@ export const AvoidJuniorsRequest = z.object({
/** `POST /player/exclusivelogin` — a bare error code. */
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
/**
* The networking feature flags the client reads off its connection info — verbatim
* from the reference server. The client changes how it replicates based on these, so
* they are not free to tune. `shouldUseGameServerNetworking` is the load-bearing one:
* true points the client at a local game server (127.0.0.1:7777) instead of Photon.
*/
export const ConnectionExperiments = z.object({
networkTransformSyncInterval: z.number(),
shouldUseUnreliableOnChange: z.boolean(),
shouldAvoidDiscontinuityRPCs: z.boolean(),
shouldAvoidRedundantDiscontinuity: z.boolean(),
r2RuntimeStaticBaking: z.boolean(),
r2AutoEmbodiment: z.boolean(),
r2RuntimeStaticBakingMinShapeThreshold: z.int(),
r2UseCheapReplicas: z.boolean(),
shouldUseGameServerNetworking: z
.boolean()
.describe('true connects to a local game server instead of Photon'),
})
/**
* `GET /player/connection-info` — the realtime (Photon) credentials, in a
* `{ success, value, error }` envelope. The applications and region are fixed for
* recflare; what varies per caller is `photonAuthToken` (minted for them on the spot)
* and `photonRoomId`, the Photon room of the instance their presence says they're in
* — the same name every other player in that instance is handed. There's no separate
* voice server, so both voice fields are null. `photonRegion` matches the one stamped
* on every room instance, so the two can't disagree.
*/
export const ConnectionInfo = z.object({
photonAuthToken: z.string().describe('Short-lived HS256 token identifying the caller to Photon'),
photonRealtimeAppId: z.string().describe('Photon Realtime application id'),
photonVoiceAppId: z.string().describe('Photon Voice application id'),
photonChatAppId: z.string().describe('Photon Chat application id'),
photonRegion: z.string().describe('Region id, matching a room instances `photonRegion`'),
photonRoomId: z.string().describe('The callers current instance; empty when theyre in none'),
voiceConnectionInfo: z.null().describe('Null — no separate voice server'),
voiceServerId: z.null().describe('Null — no separate voice server'),
experiments: ConnectionExperiments,
})
/** `GET /player/connection-info` — the connection info in the client's standard envelope. */
export const ConnectionInfoResponse = z.object({
success: z.literal(true),
value: ConnectionInfo,
error: z.null(),
})
/**
* One QoS probe target (`GET /player/qos`) — a region the client pings to measure
* latency, then reports back through `PUT /player/photonregionpings`. A bare array,
* not the `{ success, value, error }` envelope. `id` is the region id the pings are
* keyed by; `address` is `host:port`, not a URL.
*/
export const QosRegion = z.object({
id: z.string().describe('Region id, e.g. `us-east1`'),
address: z.string().describe('`host:port` of the probe endpoint'),
})
/**
* The session `LoginLock` GUID form field. The client posts it on every presence
* lifecycle call — `POST /player/login`, `/player/exclusivelogin`, `/player/logout`,
+158
View File
@@ -858,6 +858,25 @@ describe('public endpoints', () => {
expect(res.status).toBe(200)
})
test('GET /player/connection-info 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`)
expect(res.status).toBe(401)
})
test('GET /player/qos returns the probe targets', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/qos`)
expect(res.status).toBe(200)
// A bare array, not the { success, value, error } envelope connection-info uses.
expect(await res.json()).toEqual([
{ id: 'us-west1', address: '34.169.254.144:50000' },
{ id: 'europe-west1', address: '35.205.141.119:50000' },
{ id: 'asia-northeast1', address: '35.200.67.228:50000' },
{ id: 'us-east1', address: '34.73.244.122:50000' },
{ id: 'us-central1', address: '34.69.179.51:50000' },
{ id: 'northamerica-northeast1', address: '34.152.4.100:50000' },
])
})
test('PUT /player/photonregionpings returns 200', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/photonregionpings`, { method: 'PUT' })
expect(res.status).toBe(200)
@@ -899,6 +918,103 @@ describe('auth-gated endpoints', () => {
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
})
test('GET /player/connection-info hands back the Photon room the caller matchmade into', async () => {
const matchmaked = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
method: 'POST',
headers: await bearer('960'),
})
).json()) as { roomInstance: { photonRoomId: string } }
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('960'),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: true,
value: {
// A signed JWT, not an opaque id — three base64url segments.
photonAuthToken: expect.stringMatching(/^[\w-]+\.[\w-]+\.[\w-]+$/),
photonRealtimeAppId: '',
photonVoiceAppId: '',
photonChatAppId: '',
// Matches the region every room instance is stamped with.
photonRegion: 'us',
// The room the client is told to join has to be the one matchmaking placed
// them in, or they end up alone in a room of their own.
photonRoomId: matchmaked.roomInstance.photonRoomId,
voiceConnectionInfo: null,
voiceServerId: null,
experiments: {
networkTransformSyncInterval: 10,
shouldUseUnreliableOnChange: false,
shouldAvoidDiscontinuityRPCs: true,
shouldAvoidRedundantDiscontinuity: false,
r2RuntimeStaticBaking: true,
r2AutoEmbodiment: true,
r2RuntimeStaticBakingMinShapeThreshold: 1,
r2UseCheapReplicas: true,
// true would send the client to a local game server instead of Photon.
shouldUseGameServerNetworking: false,
},
},
error: null,
})
})
test('GET /player/connection-info mints a token carrying the callers id', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('961'),
})
const body = (await res.json()) as {
value: { photonAuthToken: string; photonRealtimeAppId: string }
}
const claims = JSON.parse(atob(body.value.photonAuthToken.split('.')[1]!)) as {
sub: string
aud: string
exp: number
'rn.env': string
}
expect(claims.sub).toBe('961')
// Scoped to the realtime app the same response hands out — a placeholder empty
// string until PHOTON_APPS moves to wrangler vars, so assert the two agree rather
// than pinning the placeholder itself.
expect(claims.aud).toBe(body.value.photonRealtimeAppId)
expect(claims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000))
// The client is built against prod regardless of which environment we run in.
expect(claims['rn.env']).toBe('prod')
})
test('GET /player/connection-info falls back to ?roomInstanceId when presence has no room', async () => {
// Player 962 never matchmade, so there's no presence to read the room from; the
// param names the instance they're trying to connect to.
const instance = await createRoomInstance(env.DB, {
roomId: 2,
subRoomId: 2,
roomInstanceType: 0,
photonRoomId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
maxCapacity: 12,
isPrivate: false,
ownerAccountId: 962,
})
const res = await exports.default.fetch(
`${ORIGIN}/player/connection-info?roomInstanceId=${instance.roomInstanceId}`,
{ headers: await bearer('962') }
)
expect(res.status).toBe(200)
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
})
test('GET /player/connection-info serves an empty photonRoomId when nothing resolves', async () => {
const res = await exports.default.fetch(`${ORIGIN}/player/connection-info`, {
headers: await bearer('963'),
})
const body = (await res.json()) as { value: { photonRoomId: string } }
expect(body.value.photonRoomId).toBe('')
})
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
// The client keys the room transition off a changing roomInstanceId; handing back
// the instance the player is already in hangs their join. RecCenter (cap 12) so
@@ -952,6 +1068,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.
@@ -2019,6 +2174,8 @@ describe('auth-gated endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /player',
'GET /player/avoidjuniors',
'GET /player/connection-info',
'GET /player/qos',
'GET /room/{roomId}/instances',
'GET /rooms/requiring/developer',
'GET /rooms/requiring/rrplus',
@@ -2027,6 +2184,7 @@ describe('auth-gated endpoints', () => {
'POST /matchmake/dorm',
'POST /matchmake/event/{eventId}',
'POST /matchmake/instance/{instanceId}',
'POST /matchmake/none',
'POST /matchmake/player/{playerId}',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',
+11 -4
View File
@@ -7,11 +7,18 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
Notifications, …).
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
`rec.example.com` in `wrangler.jsonc` for local dev.
service → subdomain map in `src/endpoints.ts`, with the `SUBDOMAINS` var applied
on top. Both vars are injected at deploy time from `RECFLARE_DOMAIN` and
`RECFLARE_SUBDOMAINS` (see `run-wrangler-deploy`) and default to
`rec.example.com` / `{}` in `wrangler.jsonc` for local dev.
## Updating endpoints
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
- To add or rename a service host, edit the map in `src/endpoints.ts`.
- To point one service at a different host, add it to `RECFLARE_SUBDOMAINS` (in
`.env`) and redeploy. It's keyed by the service's _default_ subdomain — the
same object `run-wrangler-deploy` reads to pick a worker's host, so an entry
moves the deployed worker and the advertised host together. An entry for a
service with no worker (e.g. `{"moderation":"api"}`) is a pure client-side
redirect onto a host another worker already serves.
- To add or rename a service, edit the map in `src/endpoints.ts`.
+8
View File
@@ -8,6 +8,14 @@ export type Env = SharedHonoEnv & {
* for local dev and tests.
*/
DOMAIN: string
/**
* Per-service subdomain overrides as a raw JSON object keyed by default subdomain,
* e.g. `{"moderation":"api"}`. The operator's `RECFLARE_SUBDOMAINS`, injected at deploy
* time via `--var SUBDOMAINS`; defaults to `{}` in `wrangler.jsonc`. See
* `parseOverrides` in `endpoints.ts`.
*/
SUBDOMAINS: string
}
/** Variables can be extended */
+46 -5
View File
@@ -1,9 +1,11 @@
/**
* Service-discovery map: service label subdomain. The game client fetches the
* Service-discovery map: service label default subdomain. The game client fetches the
* generated `{ label: "https://<subdomain>.<domain>" }` document from `/`.
*
* The base domain is injected at deploy time via the `DOMAIN` var (see
* `run-wrangler-deploy`), so the real domain never lives in a versioned file.
* `run-wrangler-deploy`), so the real domain never lives in a versioned file. The
* subdomains here are defaults an operator redirects any of them from `.env`, see
* `applyOverrides` below.
*/
const SERVICE_SUBDOMAINS = {
Accounts: 'accounts',
@@ -44,9 +46,48 @@ const SERVICE_SUBDOMAINS = {
WWW: 'www',
} as const
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
export function buildEndpoints(domain: string): Record<string, string> {
/**
* Parses the `SUBDOMAINS` var the operator's `RECFLARE_SUBDOMAINS` object, injected at
* deploy time by `run-wrangler-deploy`.
*
* It is keyed by the DEFAULT subdomain above, not by the service label, because the deploy
* script reads the very same object keyed by a worker's directory name — and every worker's
* directory name is its default subdomain. So one `.env` entry moves both sides at once:
* `{"playersettings":"settings"}` both deploys the `playersettings` worker onto
* `settings.<domain>` and advertises that host to the client. Entries naming a service with
* no worker of its own are pure client-side redirects `{"moderation":"api"}` points the
* client's Moderation calls at the `api` worker, which is where the
* `/api/PlayerReporting/…` routes actually live.
*
* A malformed value is ignored rather than thrown: this document is the first thing the
* client fetches, so a typo in `.env` should cost one redirect, not every service host.
*/
function parseOverrides(subdomains: string | undefined): Record<string, string> {
if (!subdomains) return {}
let parsed: unknown
try {
parsed = JSON.parse(subdomains)
} catch {
return {}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
return Object.fromEntries(
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
Object.entries(parsed).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1] !== ''
)
)
}
/**
* Builds the endpoints document for `domain`, e.g. `rec.example.com`, applying any
* subdomain overrides from `subdomains` (the raw `SUBDOMAINS` var JSON).
*/
export function buildEndpoints(domain: string, subdomains?: string): Record<string, string> {
const overrides = parseOverrides(subdomains)
return Object.fromEntries(
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
label,
`https://${overrides[sub] ?? sub}.${domain}`,
])
)
}
+1 -1
View File
@@ -29,6 +29,6 @@ const app = new Hono<App>()
.notFound(withNotFound())
// Endpoints document, derived from the deploy-time base domain.
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.SUBDOMAINS)))
export default app
+13
View File
@@ -18,6 +18,19 @@ describe('ns endpoints', () => {
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
})
test('a subdomain override redirects that service only', () => {
const endpoints = buildEndpoints(TEST_DOMAIN, '{"moderation":"api"}')
expect(endpoints.Moderation).toBe(`https://api.${TEST_DOMAIN}`)
expect(endpoints.API).toBe(`https://api.${TEST_DOMAIN}`)
expect(endpoints.Accounts).toBe(`https://accounts.${TEST_DOMAIN}`)
})
test('a malformed override object is ignored', () => {
for (const bad of ['', '{', 'null', '[]', '{"moderation":42}', '{"moderation":""}']) {
expect(buildEndpoints(TEST_DOMAIN, bad)).toEqual(buildEndpoints(TEST_DOMAIN))
}
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
+2 -1
View File
@@ -15,6 +15,7 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
"SUBDOMAINS": "{}" // per-service subdomain overrides; overridden during deployment
}
}
+6
View File
@@ -33,6 +33,10 @@ export interface Account {
username: string
displayName: string
profileImage: string
/** Profile banner image key. No route sets it yet, so it's `""` on every account. */
bannerImage: string
/** The emoji shown beside the display name. No route sets it yet — always `""`. */
displayEmoji: string
isJunior: boolean
platforms: number
personalPronouns: number
@@ -164,6 +168,8 @@ export function defaultAccount(id: number, overrides: Partial<Account> = {}): Ac
username: `Player${id}`,
displayName: `Player${id}`,
profileImage: 'DefaultProfileImage.jpg',
bannerImage: '',
displayEmoji: '',
isJunior: false,
platforms: 0,
personalPronouns: 0,
+1
View File
@@ -8,6 +8,7 @@ export * from './room-instance-db'
export * from './presence-db'
export * from './gifts-db'
export * from './inventory-invention-db'
export * from './outfits-db'
export * from './progression-db'
export * from './relationships-db'
export * from './validation'
@@ -1,7 +1,6 @@
/**
* Saved outfits on the shared `recflare` D1 database the outfit slots a player
* saves from the avatar screen (`POST /api/avatar/v3/saved/set`) and loads back from
* `GET /api/avatar/v3/saved`.
* saves from the avatar screen.
*
* One row per (account, slot). The outfit itself is stored as the opaque JSON payload
* the client posted: we never query inside it, and its fields (OutfitSelectionsV2,
@@ -9,11 +8,19 @@
* serializer. Round-tripping it verbatim is both the simplest and the safest thing
* re-encoding risks changing a payload the client has to parse back.
*
* The `econ` worker owns this table and its migration (apps/econ/migrations/
* 0002_outfit.sql).
* The `econ` worker owns the schema/migration (apps/econ/migrations/0002_outfit.sql) and
* serves the slot list (`GET /api/avatar/v3/saved`, `POST /api/avatar/v3/saved/set`). The
* `api` worker reads and writes SLOT 0 through `/outfits/me` the newer client treats
* slot 0 as the outfit currently worn. Both import these helpers so the table name and
* row shape live in one place.
*
* Note the two write paths store DIFFERENT payload shapes into the same column: econ's
* saved-outfit slots hold the old flat PascalCase outfit, while `/outfits/me` holds the
* newer `{ DataVersion, LegacyData, CustomizationSettings, … }` envelope. Each endpoint
* serves back what it stored, so don't add a projection that assumes either one.
*/
/** Schema DDL (mirror of migrations 0002_outfit.sql) — also used to build the table in tests. */
/** Schema DDL (mirror of apps/econ/migrations/0002_outfit.sql) — also builds the table in tests. */
export const OUTFIT_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS outfit (
account_id INTEGER NOT NULL,
@@ -27,13 +34,15 @@ export const OUTFIT_SCHEMA_DDL: string[] = [
* A saved outfit, as the client posts it. `Slot` is the outfit slot it occupies (the
* `set_id` column) saving to a slot the player already used overwrites it, which is
* exactly what the avatar screen's "save over this outfit" does. The rest of the
* payload (PreviewImageName, OutfitSelections, FaceFeatures, SkinColor, HairColor,
* CustomAvatarItems, ) is stored and served back untouched.
* payload is stored and served back untouched.
*/
export interface Outfit extends Record<string, unknown> {
Slot: number
}
/** The slot the newer client wears — what `/outfits/me` reads and writes. */
export const CURRENT_OUTFIT_SLOT = 0
/** Every outfit a player has saved, ordered by slot. */
export async function getOutfits(db: D1Database, accountId: number): Promise<Outfit[]> {
const { results } = await db
@@ -43,6 +52,19 @@ export async function getOutfits(db: D1Database, accountId: number): Promise<Out
return results.map((r) => JSON.parse(r.avatar) as Outfit)
}
/** One slot's outfit, or null when the player has never saved into it. */
export async function getOutfit(
db: D1Database,
accountId: number,
slot: number
): Promise<Outfit | null> {
const row = await db
.prepare('SELECT avatar FROM outfit WHERE account_id = ?1 AND set_id = ?2')
.bind(accountId, slot)
.first<{ avatar: string }>()
return row ? (JSON.parse(row.avatar) as Outfit) : null
}
/**
* Save an outfit into one of the player's slots, replacing whatever was there. The
* upsert is keyed on (account_id, set_id), so re-saving a slot overwrites rather than
+14 -4
View File
@@ -33,11 +33,21 @@ export const PRESENCE_TTL_SECONDS = 900
export const GAME_VERSION = '20230414'
/**
* Client builds this server treats as current. `GAME_VERSION` is the one we report for
* ourselves; the rest are additional builds `/api/versioncheck/v4` answers "current"
* for, so a player on one of them isn't pushed into an update loop.
* Client builds `/api/versioncheck/v4` answers "current" for. `GAME_VERSION` is the one
* the rest of the stack targets and reports for itself; the others are later clients
* that talk close enough to the same protocol to get past the update prompt.
*
* DEBUGGING ONLY beyond `GAME_VERSION`: this is not a supported-version list. Nothing
* else in the stack targets those builds, so a client waved through here can still hit
* protocol differences the version check would otherwise have caught. Trim it back to
* `GAME_VERSION` alone before anyone but us is playing.
*/
export const SUPPORTED_GAME_VERSIONS: string[] = [GAME_VERSION, '20250424.01']
export const SUPPORTED_GAME_VERSIONS: string[] = [
GAME_VERSION,
'20230616',
'20231207',
'20250424.01',
]
/** Whether a client-supplied build (the version check's `?v=`) is one we serve. */
export function isSupportedGameVersion(version: string | null | undefined): boolean {
+2
View File
@@ -2,5 +2,7 @@ export {
validateAndGetAccountId,
validateAndGetRoles,
generateToken,
generatePhotonAuthToken,
TOKEN_TTL_SECONDS,
} from './jwt'
export type { PhotonAuthClaims } from './jwt'
+49
View File
@@ -108,6 +108,55 @@ const TOKEN_SCOPES = [
*/
const BASE_ROLES = ['gameClient']
/**
* The claims the Photon auth token carries beyond `sub`/`exp`/`aud`, describing who
* (and on what) is connecting. All of them go on the wire as STRINGS, including the
* numeric ones that's how the real token encodes them.
*/
export interface PhotonAuthClaims {
/** The platform-native id (e.g. a SteamID64) — `rn.platid`. */
platformId: string
/** PlatformType int (0 = Steam) — `rn.plat`. */
platform: number
/** DeviceClass int (2 = PC/standalone) — `rn.deviceclass`. */
deviceClass: number
/** The Photon application the token is for — the `aud` claim. */
audience: string
}
/**
* Mint the short-lived HS256 token the client hands to Photon as its custom auth
* credential (`photonAuthToken` on `GET /player/connection-info`). The claim set
* mirrors the real one `sub`, `rn.platid`, `rn.plat`, `rn.deviceclass`, `rn.env`,
* `exp`, `aud` rather than being a second copy of the login token: it identifies
* the connecting player to the realtime server and nothing else, so none of the
* scopes or roles from {@link generateToken} belong on it.
*
* Signed with the same shared `JWT_SECRET` as every other token here. A real Photon
* Cloud application would verify this against a secret configured in its dashboard;
* self-hosted, nothing verifies it yet so treat it as identifying, not authorizing.
* `rn.env` is `prod` because that's what the client is built against, regardless of
* which environment this worker is running in.
*/
export async function generatePhotonAuthToken(
accountId: number,
claims: PhotonAuthClaims,
secret: string
): Promise<string> {
return sign(
{
sub: String(accountId),
'rn.platid': claims.platformId,
'rn.plat': String(claims.platform),
'rn.deviceclass': String(claims.deviceClass),
'rn.env': 'prod',
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
aud: claims.audience,
},
secret
)
}
export async function generateToken(
accountId: string,
platformId: string,
+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
@@ -190,6 +197,7 @@ wrangler deploy \
--var NAME:"$NAME" \
--var SENTRY_RELEASE:"$VERSION" \
--var DOMAIN:"$DOMAIN" \
--var SUBDOMAINS:"$SUBDOMAINS_JSON" \
$EXTRA_VARS \
--domain "$HOST" \
$MINIFY \