mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client Version check now answers "current" for a set of builds rather than one: SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is unchanged and still what the server reports for itself (presence, rn.ver). Adds GET /api/versioncheck/islandedversions, always [] — we never island a build off into its own matchmaking pool. The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a deviceId/platformAuth/time form body where the 2023 build GETs it, so that route now takes both methods. The body is accepted and ignored for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
@@ -62,6 +63,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', inventoryRoutes)
|
||||
.route('/', roomRoutes)
|
||||
.route('/', imageRoutes)
|
||||
.route('/', accountRoutes)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
|
||||
@@ -24,6 +24,14 @@ export type Env = SharedHonoEnv & {
|
||||
// Shared CDN bucket (owned by the `cdn` worker, written by `storage`). Read
|
||||
// here only to hash an invention's uploaded data blob under `invention/`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
/**
|
||||
* The per-player settings map the `playersettings` worker owns (`player:<id>` → JSON
|
||||
* `{ key: value }`). Read and written here by
|
||||
* `GET|PUT /api/players/v1/playerPhotoTaggingSetting`: the photo-tagging preference is
|
||||
* one key (`playerPhotoTaggingSetting`) in that shared bag, not a store of its own, so
|
||||
* the write MERGES — see `writePhotoTaggingSetting`.
|
||||
*/
|
||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||
// SignalR notifications hub (DO owned by the `notify` worker). Bound here to
|
||||
// push RelationshipChanged notifications when a player's relationship changes.
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
|
||||
+202
-6
@@ -137,6 +137,14 @@ export const BacktraceConfig = z.object({
|
||||
VersionRegex: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /statsigUserProperties` — the reference server answers this with a single
|
||||
* `success` carrying its `StatsigEnabled` config value, a BOOL rather than an int.
|
||||
*/
|
||||
export const StatsigUserProperties = z.object({
|
||||
success: z.boolean().describe('The Statsig-enabled flag (true)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/config/v2` — the big client config blob (a static asset), with
|
||||
* `ShareBaseUrl` derived from the deploy-time base domain.
|
||||
@@ -145,7 +153,13 @@ export const ApiConfigV2 = JsonObject.describe(
|
||||
'The static client config, plus a ShareBaseUrl templated from the deploy domain'
|
||||
)
|
||||
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build matches GAME_VERSION. */
|
||||
/**
|
||||
* `GET /api/versioncheck/islandedversions` — builds islanded onto their own matchmaking
|
||||
* pool. Always empty here.
|
||||
*/
|
||||
export const IslandedVersions = z.array(z.string())
|
||||
|
||||
/** `GET /api/versioncheck/v4` — whether the client's `?v=` build is one we serve. */
|
||||
export const VersionCheck = z.object({
|
||||
VersionStatus: z.int().describe('0 = current, 1 = client on a different build'),
|
||||
UpdateNotificationStage: z.int(),
|
||||
@@ -199,6 +213,17 @@ export const SendMultipleMessagesRequest = z.object({
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
|
||||
* online, wrapped in the client's `{ success, value }` envelope.
|
||||
*/
|
||||
export const FriendOnlineCountResponse = z.object({
|
||||
success: z.boolean(),
|
||||
value: z.object({
|
||||
FriendsOnlineCount: z.int().describe('Friends with live presence right now'),
|
||||
}),
|
||||
})
|
||||
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
@@ -411,12 +436,118 @@ export const GenerateGiftRequest = z.object({
|
||||
Xp: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/customAvatarItems/v1/bulk` form body. A repeated form field, not a JSON
|
||||
* array: the reference binds `[FromForm] List<string>`, so the client posts
|
||||
* `customAvatarItemIds=a&customAvatarItemIds=b`.
|
||||
*/
|
||||
export const BulkCustomAvatarItemsRequest = z.object({
|
||||
customAvatarItemIds: z
|
||||
.array(z.string())
|
||||
.describe('The ids to resolve; repeat the field once per id'),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
|
||||
export const CustomAvatarItemsPage = z.object({
|
||||
Results: JsonArray,
|
||||
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 save’s 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 client’s 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(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /outfits/me` — the base envelope, with NO `Value` key: three keys and that is the
|
||||
* whole body. The save answers only whether it worked; the client keeps the outfit it just
|
||||
* sent rather than re-rendering from a response, so nothing here echoes the outfit back.
|
||||
*
|
||||
* Note the mixed casing — `Success` and `Error` are PascalCase, `error_id` is snake_case.
|
||||
* That is what the reference sends, and the client's decoder matches on the exact names, so
|
||||
* do not "tidy" it into one convention.
|
||||
*/
|
||||
export const OutfitSaveResponse = z.object({
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success'),
|
||||
error_id: z.string().nullable().describe('Null on success. snake_case, unlike its siblings'),
|
||||
})
|
||||
|
||||
/** The `{ success, value }` envelope `isCreationAllowedForAccount` wraps its answer in. */
|
||||
export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.null() })
|
||||
|
||||
@@ -582,13 +713,27 @@ 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.
|
||||
* One row of `GET /api/PlayerReporting/v1/voteToKickReasons` — the label the client puts
|
||||
* on a vote-to-kick button, and the report category the kick is filed under if it passes.
|
||||
*/
|
||||
export const VoteToKickReason = z.object({
|
||||
Reason: z.string().describe('The label shown on the button'),
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.describe('The category the resulting report is filed under: 101, 102, 103 or 6'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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(),
|
||||
@@ -687,6 +832,29 @@ export const SavedImageDto = z.object({
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
|
||||
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
|
||||
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
|
||||
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
|
||||
* other two.
|
||||
*/
|
||||
export const ImageMetadataDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
PlayerId: z.int(),
|
||||
RoomId: z.int().describe('0 when the photo was not taken in a room'),
|
||||
PlayerEventId: z.int().describe('0 when it belongs to no event'),
|
||||
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
|
||||
Description: z.string().describe('Empty string, never null'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
|
||||
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
||||
@@ -745,12 +913,40 @@ export const UploadImageResponse = z.object({
|
||||
/** `DELETE /api/images/v1/deletesaved` JSON body. */
|
||||
export const DeleteImageRequest = z.object({ ImageName: z.string() })
|
||||
|
||||
/**
|
||||
* `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
|
||||
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
|
||||
* whole photo-grid page this way, around a hundred ids at a time.
|
||||
*/
|
||||
export const CheeredBulkRequest = z.object({
|
||||
id: z.string().describe('Repeated once per image id; each value may also be comma-separated'),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v1/cheer` JSON body. */
|
||||
export const CheerImageRequest = z.object({
|
||||
SavedImageId: z.int(),
|
||||
Cheer: z.boolean().describe('True to cheer, false to un-cheer'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/players/v1/playerPhotoTaggingSetting` JSON body — who may tag the caller in
|
||||
* photos. The value is an opaque enum ordinal: it is stored and served back untouched, so
|
||||
* whatever the client means by a given number survives a round trip without this server
|
||||
* needing to know the enum.
|
||||
*/
|
||||
export const PhotoTaggingSettingRequest = z.object({
|
||||
Setting: z.int().describe('The preference’s enum ordinal, stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET|PUT /api/players/v1/playerPhotoTaggingSetting` — a BARE JSON integer, not an
|
||||
* envelope and not a `{ value }` wrapper. Both routes answer the setting the player now
|
||||
* has: the reference's GET and its PUT both `Ok(...)` the stored value.
|
||||
*/
|
||||
export const PhotoTaggingSettingResponse = z
|
||||
.int()
|
||||
.describe('The caller’s photo-tagging preference; 0 until they set one')
|
||||
|
||||
/** The bare `{ success: true }` ack the image writes answer with. */
|
||||
export const SuccessResponse = z.object({ success: z.boolean() })
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { json, JsonArray, stringParam } from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// ---- Account ---------------------------------------------------------------
|
||||
// The identity service's account-scoped reads. Nothing here is backed by storage — the
|
||||
// client calls it while loading the account, and an empty list is a complete answer for a
|
||||
// server that links no external channels to an account.
|
||||
export const accountRoutes = new Hono<App>({ strict: false }).get(
|
||||
'/iam/me/channels/:type',
|
||||
describeRoute({
|
||||
tags: ['Account'],
|
||||
summary: 'The caller’s channels of a type',
|
||||
description:
|
||||
'The channels of the given `{type}` linked to the caller’s account. This server ' +
|
||||
'links none, so the list is always empty — a real answer rather than a placeholder ' +
|
||||
'for one, since the client renders "nothing linked" from it. `{type}` is accepted ' +
|
||||
'but not inspected, and the route is not auth-gated: the answer is the same for ' +
|
||||
'every caller and every type.',
|
||||
parameters: [stringParam('type', 'The channel type. Accepted but not inspected.')],
|
||||
responses: { 200: json(JsonArray, 'Always an empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
@@ -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'
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
BulkCustomAvatarItemsRequest,
|
||||
CustomAvatarItemsPage,
|
||||
ErrorResponse,
|
||||
form,
|
||||
@@ -46,6 +50,10 @@ import {
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
LegacyAvatarItemSaves,
|
||||
OutfitSaveResponse,
|
||||
OutfitsMeRequest,
|
||||
OutfitsMeResponse,
|
||||
pageParams,
|
||||
SaveInventionRequest,
|
||||
SetTagsRequest,
|
||||
@@ -156,6 +164,28 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// A batch lookup of LOCKED avatar items — the items the client shows greyed out, so it
|
||||
// posts the ids it wants the locked state for. Nothing here locks avatar items (the
|
||||
// catalogs `econ` serves are all unlocked), so nothing comes back and the client renders
|
||||
// none as locked. Unlike its custom-item sibling above this one is NOT auth-gated: the
|
||||
// reference answers the empty array outright, without validating a token first.
|
||||
.post(
|
||||
'/api/avatar/v1/lockeditems/bulk',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Locked avatar items in bulk',
|
||||
description:
|
||||
'Resolves a batch of avatar-item ids to the ones that are LOCKED for the caller, as ' +
|
||||
'a bare array. Nothing on this server locks avatar items, so it is always `[]` and ' +
|
||||
'the posted ids are not parsed — a miss is not an error, the client simply renders ' +
|
||||
'nothing as locked.\n\n' +
|
||||
'No auth, matching the reference, which returns the empty array without checking a ' +
|
||||
'token — in contrast to `/api/customAvatarItems/v1/bulk`, which validates one first.',
|
||||
responses: { 200: json(JsonArray, 'The locked items — always empty here') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||||
// implementation yet; we enable them. Flip to `false` to disable the
|
||||
// corresponding flow. `isCreationAllowedForAccount` wraps its answer in the
|
||||
@@ -218,6 +248,42 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// A batch lookup of custom avatar items by id. The reference filters a static catalog
|
||||
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
|
||||
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
|
||||
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
|
||||
// ships empty too), so every id misses and the array is empty.
|
||||
//
|
||||
// Auth-gated, and the token is checked before anything else, as the reference does.
|
||||
.post(
|
||||
'/api/customAvatarItems/v1/bulk',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Custom avatar items in bulk',
|
||||
description:
|
||||
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
|
||||
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
|
||||
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
|
||||
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
|
||||
'answers this route with the filtered array alone.\n\n' +
|
||||
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
|
||||
'client reads the items it got back rather than the ids it asked for. Nothing ' +
|
||||
'stores custom items here, so every id misses and this is always `[]` — which is ' +
|
||||
'why the posted ids are not parsed.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
|
||||
responses: {
|
||||
200: json(JsonArray, 'The matching items — always empty here'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// Custom avatar items created by a given account. No storage yet → an empty
|
||||
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
|
||||
.get(
|
||||
@@ -234,6 +300,147 @@ 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 item’s `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 caller’s 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 payload’s heavy fields are the ' +
|
||||
'client’s 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 bare `{ Success, Error, error_id }` envelope — no `Value` key, and NOT the
|
||||
// outfit that was just saved: the client keeps what it sent and only reads whether the
|
||||
// save worked.
|
||||
.put(
|
||||
'/outfits/me',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'Save the caller’s 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: its heavy fields (`SelectionsV2`, `FaceFeatures`, ' +
|
||||
'`CustomizationSettings`) are whole JSON documents encoded as strings by the ' +
|
||||
'client’s own serializer, so nothing here parses or re-encodes them.\n\n' +
|
||||
'The response is the base envelope with no `Value` key — three keys, and the outfit ' +
|
||||
'is not echoed back. The mixed casing (`Success`/`Error` but `error_id`) is the ' +
|
||||
'reference’s, not a typo.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OutfitsMeRequest, 'The outfit to save'),
|
||||
responses: {
|
||||
200: json(OutfitSaveResponse, 'Saved — `{ Success: true, Error: null, error_id: null }`'),
|
||||
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({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
|
||||
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
|
||||
// belong in this list (and in what shape) has not been pinned down, so it answers []
|
||||
// rather than guessing.
|
||||
.get(
|
||||
'/outfits/me/saved',
|
||||
describeRoute({
|
||||
tags: ['Avatar'],
|
||||
summary: 'The caller’s saved outfits',
|
||||
description:
|
||||
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
|
||||
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
|
||||
'list should carry, and in what shape, is not pinned down yet.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(JsonArray, 'An empty list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// A single invention by id (`?inventionId=…`). Returns the stored RRInvention,
|
||||
// or 404 when there's no such invention.
|
||||
.get(
|
||||
@@ -720,6 +927,33 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
|
||||
// shelf, and the "from creators you follow" row, from.
|
||||
//
|
||||
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
|
||||
// the client reads it as "this creator has published nothing" and renders an empty
|
||||
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
|
||||
// a filter on the invention table's creator column, the same feed shape as `toptoday`
|
||||
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
|
||||
.get(
|
||||
'/api/inventions/v1/fromcreators',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Inventions by particular creators (stub)',
|
||||
description:
|
||||
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
|
||||
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
|
||||
'array for now, which the client renders as "nothing published" rather than as a ' +
|
||||
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
|
||||
parameters: [
|
||||
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { GAME_VERSION } from '@repo/domain'
|
||||
import { isSupportedGameVersion } from '@repo/domain'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
ApiConfigV2,
|
||||
AzureSpeechConfig,
|
||||
BacktraceConfig,
|
||||
IslandedVersions,
|
||||
json,
|
||||
JsonObject,
|
||||
StatsigUserProperties,
|
||||
VersionCheck,
|
||||
} from '../openapi'
|
||||
|
||||
@@ -100,18 +102,33 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||
'our target `GAME_VERSION`: `VersionStatus` is 0 when they match, 1 when the ' +
|
||||
'client is on a different build.',
|
||||
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
|
||||
'client is on one of them, 1 when it is on some other build.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: c.req.query('v') === GAME_VERSION ? 0 : 1,
|
||||
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
})
|
||||
)
|
||||
// Islanding splits players onto version-specific matchmaking pools. We serve every
|
||||
// supported build from one pool, so the list is empty — the client reads it as
|
||||
// "nobody is islanded" and matchmakes normally.
|
||||
.get(
|
||||
'/api/versioncheck/islandedversions',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Islanded client builds',
|
||||
description:
|
||||
'The builds that are islanded off into their own matchmaking pool. This server ' +
|
||||
'never islands a build, so the list is always empty.',
|
||||
responses: { 200: json(IslandedVersions, 'Always an empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get(
|
||||
'/api/gameconfigs/v1/all',
|
||||
describeRoute({
|
||||
@@ -123,6 +140,26 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json(gameConfigsV1All)
|
||||
)
|
||||
|
||||
// The property bag the client would attach to its Statsig user. The reference server
|
||||
// doesn't send properties at all here — it answers a lone `success` carrying its
|
||||
// `StatsigEnabled` config value, as a bool — so that is what this mirrors. This server
|
||||
// runs no experiments and collects no analytics (see the placeholder keys
|
||||
// `/api/config/v1/amplitude` serves), so the value is fixed and the same for everyone.
|
||||
.post(
|
||||
'/statsigUserProperties',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Statsig user properties',
|
||||
description:
|
||||
'Despite the name, the reference server returns no properties here — just ' +
|
||||
'`success`, its `StatsigEnabled` config value as a bool. This server mirrors that ' +
|
||||
'with a fixed `true`; it runs no experiments and collects no analytics, so nothing ' +
|
||||
'here is per-account and it is not auth-gated.',
|
||||
responses: { 200: json(StatsigUserProperties, 'The fixed `StatsigEnabled` flag') },
|
||||
}),
|
||||
(c) => c.json({ success: true })
|
||||
)
|
||||
|
||||
// Voice chat config. The client fetches it to set up voice.
|
||||
// No reference shape, so return an empty object until the client needs fields.
|
||||
.get(
|
||||
|
||||
+262
-14
@@ -6,6 +6,7 @@ import {
|
||||
deleteImage,
|
||||
getCheeredImageIds,
|
||||
getImageByName,
|
||||
getImagesByIds,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
@@ -14,24 +15,29 @@ import {
|
||||
setImageCheer,
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImageMetadata,
|
||||
toImagesPlayer,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
CheeredBulkRequest,
|
||||
CheeredEntry,
|
||||
CheerImageRequest,
|
||||
DeleteImageRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
idParam,
|
||||
ImageMetadataDto,
|
||||
ImagesPlayerDto,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
PhotoTaggingSettingRequest,
|
||||
PhotoTaggingSettingResponse,
|
||||
SavedImageDto,
|
||||
SlideshowResponse,
|
||||
stringQuery,
|
||||
@@ -41,6 +47,7 @@ import {
|
||||
UploadImageResponse,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
||||
@@ -53,6 +60,123 @@ const typeFolder: Record<number, string> = {
|
||||
[SavedImageType.InventionThumbnail]: 'invention',
|
||||
}
|
||||
|
||||
/**
|
||||
* The player-settings key the photo-tagging preference is stored under, in the same
|
||||
* per-player bag the `playersettings` worker owns (`player:<id>` → `{ key: value }`). It
|
||||
* gets its own endpoints rather than being written through `/playersettings` because the
|
||||
* client asks for it by name, but there is no separate store behind it — which is why the
|
||||
* write below merges.
|
||||
*
|
||||
* Unlike the loose matching `match` does for `avoidJuniors`, the spelling is exact: nothing
|
||||
* but these two routes reads or writes this key, so there is no client spelling to guess.
|
||||
*/
|
||||
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
|
||||
|
||||
/**
|
||||
* The preference a player has before they have ever set one. The value is an opaque enum
|
||||
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
|
||||
* enum reads as — the reference's own default.
|
||||
*/
|
||||
const PHOTO_TAGGING_DEFAULT = 0
|
||||
|
||||
/** The player's settings map, or null when they have none / KV is unreachable. */
|
||||
async function getPlayerSettings(
|
||||
env: App['Bindings'],
|
||||
accountId: number
|
||||
): Promise<Record<string, string> | null> {
|
||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${accountId}`,
|
||||
'json'
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
/** The caller's stored photo-tagging preference, or the default when they have none. */
|
||||
async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number): Promise<number> {
|
||||
const stored = await getPlayerSettings(env, accountId)
|
||||
const raw = stored?.[PHOTO_TAGGING_KEY]
|
||||
const parsed = Number.parseInt(String(raw ?? ''), 10)
|
||||
return Number.isNaN(parsed) ? PHOTO_TAGGING_DEFAULT : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the preference back into the player's settings map.
|
||||
*
|
||||
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
|
||||
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
|
||||
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
|
||||
* and racing writers here means one player toggling two of their own options at once.
|
||||
*/
|
||||
async function writePhotoTaggingSetting(
|
||||
env: App['Bindings'],
|
||||
accountId: number,
|
||||
setting: number
|
||||
): Promise<void> {
|
||||
const stored = (await getPlayerSettings(env, accountId)) ?? {}
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${accountId}`,
|
||||
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The posted `Setting`, out of a JSON body (`{ "Setting": 1 }`, what the client sends) or a
|
||||
* form one. Both casings are accepted, and a numeric string parses — the value is an
|
||||
* integer either way. `undefined` when the body carries nothing readable, which the caller
|
||||
* treats as "leave it alone" rather than as a write of 0.
|
||||
*/
|
||||
async function readPostedSetting(c: Context<App>): Promise<number | undefined> {
|
||||
const body = (c.req.header('content-type') ?? '').includes('application/json')
|
||||
? ((await c.req.json().catch(() => null)) as Record<string, unknown> | null)
|
||||
: await c.req.parseBody().catch(() => null)
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body)) return undefined
|
||||
|
||||
const raw = (body as Record<string, unknown>).Setting ?? (body as Record<string, unknown>).setting
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? Math.trunc(raw) : undefined
|
||||
if (typeof raw !== 'string') return undefined
|
||||
const parsed = Number.parseInt(raw.trim(), 10)
|
||||
return Number.isNaN(parsed) ? undefined : parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* The saved-image ids a cheer lookup is asking about, from wherever the client put them.
|
||||
*
|
||||
* The client POSTs them as a form body of repeated `id` fields — a photo grid asks about a
|
||||
* whole page at once, ~100 ids, which is more than it wants to hang off a URL — and the
|
||||
* same repeated-field spelling also works as a query string, which is how the GET form of
|
||||
* this route takes them. Both are read, so one handler serves either.
|
||||
*
|
||||
* Each value may itself be a comma-separated list, and unparseable entries are dropped
|
||||
* rather than failing the request: a stray id must not cost the caller the rest of the page.
|
||||
*/
|
||||
async function cheerLookupIds(c: Context<App>): Promise<number[]> {
|
||||
const raw = [...(c.req.queries('id') ?? [])]
|
||||
if (c.req.method !== 'GET') {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'id')
|
||||
const posted = key === undefined ? [] : body[key]
|
||||
for (const value of Array.isArray(posted) ? posted : [posted]) {
|
||||
if (typeof value === 'string') raw.push(value)
|
||||
}
|
||||
}
|
||||
return raw
|
||||
.flatMap((value) => value.split(','))
|
||||
.map((value) => Number.parseInt(value.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* One `{ SavedImageId, IsCheered }` per requested id, in request order — the shared
|
||||
* handler behind both the GET and the POST form of the bulk cheer lookup. The cheer state
|
||||
* is the CALLER's, so two players asking about the same photo get different answers.
|
||||
*/
|
||||
async function cheerLookup(c: Context<App>) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids = await cheerLookupIds(c)
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) })))
|
||||
}
|
||||
|
||||
// ---- Images ----------------------------------------------------------------
|
||||
export const imageRoutes = new Hono<App>({ strict: false })
|
||||
.get(
|
||||
@@ -350,6 +474,44 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk image metadata by id (`?ids=207&ids=106`) — the client resolving a set of photo
|
||||
// ids it already holds. A bare array in REQUEST order, so it can line the records up
|
||||
// with what it asked for; an id with no record (or one that isn't public) is simply
|
||||
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
|
||||
//
|
||||
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
|
||||
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
|
||||
// where the raw record comes up blank; this is a metadata lookup.
|
||||
//
|
||||
// Public-only, as every image read here is: ids are sequential, so honouring whatever
|
||||
// id is named would hand out private photos to anyone who counts.
|
||||
.get(
|
||||
'/api/images/v5/bulk',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Image metadata by id, in bulk',
|
||||
description:
|
||||
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
|
||||
'array in request order. An id with no record, or one that is not public, is absent ' +
|
||||
'from the answer rather than an error — the list can be shorter than the request. ' +
|
||||
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
|
||||
'player photo lists use.',
|
||||
parameters: [
|
||||
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
|
||||
],
|
||||
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids =
|
||||
c.req
|
||||
.queries('ids')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
return c.json(await getImagesByIds(c.env.DB, ids))
|
||||
}
|
||||
)
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
.get(
|
||||
@@ -358,11 +520,15 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Images'],
|
||||
summary: 'Image metadata by filename',
|
||||
description:
|
||||
'The stored `SavedImage` record for a bucket key. 404s when the object exists but ' +
|
||||
'has no metadata row.',
|
||||
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
|
||||
'metadata row.\n\n' +
|
||||
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
|
||||
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
|
||||
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
|
||||
'`""`. Three shapes of one row; keep them straight.',
|
||||
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
||||
responses: {
|
||||
200: json(SavedImageDto, 'The image record'),
|
||||
200: json(ImageMetadataDto, 'The image’s metadata'),
|
||||
400: json(ErrorResponse, 'No name given'),
|
||||
404: { description: 'No metadata for that name' },
|
||||
},
|
||||
@@ -371,7 +537,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(image) : c.notFound()
|
||||
return image ? c.json(toImageMetadata(image)) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -409,6 +575,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller has cheered each of the given saved-image ids (`?id=55&id=54`,
|
||||
// and each `id` may itself be a comma-separated list). Auth-gated. Returns one
|
||||
// `{ SavedImageId, IsCheered }` per requested id, in order.
|
||||
//
|
||||
// The client actually POSTs this (see below); the GET form is kept because it is the
|
||||
// same lookup and costs one line, and a URL of ids is the easier thing to hand a
|
||||
// browser or a curl.
|
||||
.get(
|
||||
'/api/images/v5/cheered/bulk',
|
||||
describeRoute({
|
||||
@@ -426,18 +596,96 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
cheerLookup
|
||||
)
|
||||
|
||||
// The same lookup as a POST, which is the form the client sends: the ids ride in a
|
||||
// form-urlencoded body of repeated `id` fields (`id=651&id=570&…`) rather than the query
|
||||
// string, because a photo grid asks about a full page at once — around a hundred ids,
|
||||
// more than belongs in a URL. Same auth, same answer, same order.
|
||||
.post(
|
||||
'/api/images/v5/cheered/bulk',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Which photos the caller has cheered (bulk POST)',
|
||||
description:
|
||||
'One `{ SavedImageId, IsCheered }` per requested id, in request order — the client ' +
|
||||
'fills in the cheer buttons on a photo grid from this. The ids are a form body of ' +
|
||||
'repeated `id` fields (`id=651&id=570&…`), which is how the client sends a page of ' +
|
||||
'~100 at once; the query string is read too, so the GET form of this path answers ' +
|
||||
'identically.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CheeredBulkRequest, 'The image ids, as repeated `id` fields'),
|
||||
responses: {
|
||||
200: json(CheeredEntry.array(), 'One entry per requested id, in order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
cheerLookup
|
||||
)
|
||||
|
||||
// Who may tag the caller in photos. The preference lives in the player-settings bag
|
||||
// (`playerPhotoTaggingSetting`), not in a store of its own — these two routes exist
|
||||
// because the client asks for it by name rather than through `/playersettings`.
|
||||
//
|
||||
// A bare JSON integer, not an envelope, and an opaque one: the value is an enum ordinal
|
||||
// the client defines, stored and served back untouched, so it round-trips whatever the
|
||||
// client means by it. A player who has never set one reads 0.
|
||||
.get(
|
||||
'/api/players/v1/playerPhotoTaggingSetting',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'The caller’s photo-tagging preference',
|
||||
description:
|
||||
'Who may tag the caller in photos, as a bare JSON integer (the enum ordinal the ' +
|
||||
'client defines — stored and served back untouched). `0` until the player sets one. ' +
|
||||
'Stored as one key in the player-settings bag the `playersettings` worker owns.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PhotoTaggingSettingResponse, 'The caller’s setting; 0 if never set'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
const cheered = await getCheeredImageIds(c.env.DB, id, ids)
|
||||
return c.json(
|
||||
ids.map((imageId) => ({ SavedImageId: imageId, IsCheered: cheered.has(imageId) }))
|
||||
)
|
||||
return c.json(await readPhotoTaggingSetting(c.env, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Set the caller's photo-tagging preference. Answers the stored value, as the reference
|
||||
// does — the client re-renders the toggle from the response rather than from what it
|
||||
// sent.
|
||||
//
|
||||
// A body with no readable `Setting` leaves the stored preference ALONE and answers it,
|
||||
// rather than writing the 0 an unbound .NET model would have carried: the value is
|
||||
// opaque here, so a guess is indistinguishable from a real choice once it's stored.
|
||||
.put(
|
||||
'/api/players/v1/playerPhotoTaggingSetting',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set the caller’s photo-tagging preference',
|
||||
description:
|
||||
'Stores `Setting` as the caller’s photo-tagging preference and answers the stored ' +
|
||||
'value (a bare integer), which is what the client re-renders the toggle from. The ' +
|
||||
'write merges into the player-settings bag, so the player’s other settings are left ' +
|
||||
'alone. `Setting` is also read from a form body, and from a `setting` spelling; a ' +
|
||||
'body carrying no readable value is a no-op that answers the current setting.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PhotoTaggingSettingRequest, 'The preference to store'),
|
||||
responses: {
|
||||
200: json(PhotoTaggingSettingResponse, 'The setting the caller now has'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const setting = await readPostedSetting(c)
|
||||
if (setting === undefined) return c.json(await readPhotoTaggingSetting(c.env, id))
|
||||
|
||||
await writePhotoTaggingSetting(c.env, id, setting)
|
||||
return c.json(setting)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ModerationBlockDetails,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
VoteToKickReason,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
@@ -57,15 +58,44 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/**
|
||||
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
|
||||
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
|
||||
* and 6 for the game-conduct reasons, which are kick-worthy without being a policy
|
||||
* violation of their own.
|
||||
*/
|
||||
const VOTE_TO_KICK_REASONS = [
|
||||
{ Reason: 'Discriminatory language', ReportCategory: 102 },
|
||||
{ Reason: 'Discriminatory behavior', ReportCategory: 102 },
|
||||
{ Reason: 'Threats or encouraging suicide', ReportCategory: 102 },
|
||||
{ Reason: 'Toxic behavior', ReportCategory: 102 },
|
||||
{ Reason: 'Sexual behavior in public', ReportCategory: 101 },
|
||||
{ Reason: 'Sexual language in public', ReportCategory: 101 },
|
||||
{ Reason: 'Non-consensual sexual behavior', ReportCategory: 101 },
|
||||
{ Reason: 'Player in walls or floor', ReportCategory: 103 },
|
||||
{ Reason: 'Friendly fire', ReportCategory: 103 },
|
||||
{ Reason: 'Microphone spam', ReportCategory: 103 },
|
||||
{ Reason: 'Abusing bugs or exploits', ReportCategory: 103 },
|
||||
{ Reason: 'Spawn camping', ReportCategory: 103 },
|
||||
{ Reason: 'Inactive in games (AFK)', ReportCategory: 6 },
|
||||
{ Reason: 'Prefab swapping', ReportCategory: 6 },
|
||||
{ Reason: 'Not following game rules', ReportCategory: 6 },
|
||||
] as const
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
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 +103,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 server’s 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) =>
|
||||
@@ -93,18 +125,69 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
)
|
||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||
// the client renders them in the order they arrive — and the list is grouped by the
|
||||
// `ReportCategory` the resulting report is filed under, hate first, then sexual
|
||||
// content, then griefing, then the game-conduct reasons. Fixed and the same for
|
||||
// everyone, but auth-gated all the same, as the reference is.
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/voteToKickReasons',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Vote-to-kick reasons',
|
||||
description:
|
||||
'The reasons offered when starting a vote-to-kick. Not hydrated yet, so the list ' +
|
||||
'is empty.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
'The reasons offered when starting a vote-to-kick, each with the `ReportCategory` ' +
|
||||
'the report is filed under if the vote carries: 102 hate, 101 sexual content, 103 ' +
|
||||
'griefing, 6 game conduct. A fixed list, in the order the client renders it.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(VoteToKickReason.array(), 'The reasons, in render order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(VOTE_TO_KICK_REASONS)
|
||||
}
|
||||
)
|
||||
// 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([])
|
||||
) // TODO: hydrate from JSON/vtkreasons.json
|
||||
)
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/hile',
|
||||
describeRoute({
|
||||
|
||||
@@ -224,3 +224,24 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// The progression events running right now — the limited-time XP events the client shows
|
||||
// a banner and a progress track for.
|
||||
//
|
||||
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
|
||||
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
|
||||
// fabricated event would draw a track that never fills. No auth — whether an event is
|
||||
// running is the same fact for everybody, and the client asks while loading.
|
||||
.get(
|
||||
'/api/progressionEvents/active',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progression events currently running (stub)',
|
||||
description:
|
||||
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
|
||||
'server runs one — which the client reads as “no event” and skips the event UI, ' +
|
||||
'where a 404 would stall the load. No auth: it is the same answer for every player.',
|
||||
responses: { 200: json(JsonArray, 'Empty — no event is running') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describeRoute } from 'hono-openapi'
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
countOnlineFriends,
|
||||
getAccountsByIds,
|
||||
getMutualFriendIds,
|
||||
getRelationshipsForPlayer,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
AUTHED,
|
||||
ErrorResponse,
|
||||
form,
|
||||
FriendOnlineCountResponse,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
@@ -622,6 +624,37 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
// How many of the caller's friends are online — the friends panel's header count.
|
||||
// Answered from the friend graph joined to live presence, so it agrees with the
|
||||
// friends the panel then lists. Auth-gated: the count is the CALLER's own.
|
||||
.post(
|
||||
'/api/messages/v1/friendOnlineStatus',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'How many friends are online',
|
||||
description:
|
||||
'The caller’s `Friend` relationships joined to live `presence`. Only unexpired ' +
|
||||
'presence counts, and friends in the lobby (no room instance) count too — they ' +
|
||||
'are signed in, just not in a room.\n\n' +
|
||||
'A friend’s `statusVisibility` is not consulted: nothing else in the stack filters ' +
|
||||
'presence on it, so hiding people here would disagree with the list the client ' +
|
||||
'renders underneath the count.\n\n' +
|
||||
'A POST that takes no body — the player is the bearer token.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(FriendOnlineCountResponse, 'The caller’s online-friend count'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
success: true,
|
||||
value: { FriendsOnlineCount: await countOnlineFriends(c.env.DB, id) },
|
||||
})
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/api/messages/v1/favoriteFriendOnlineStatus',
|
||||
describeRoute({
|
||||
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
LEVEL_REQUIRED_XP,
|
||||
LEVEL_REWARDS,
|
||||
MAX_LEVEL,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RELATIONSHIP_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
SUPPORTED_GAME_VERSIONS,
|
||||
} from '@repo/domain'
|
||||
|
||||
import '../../api.app'
|
||||
@@ -107,6 +111,12 @@ 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()
|
||||
|
||||
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
|
||||
for (const stmt of PRESENCE_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()
|
||||
|
||||
@@ -197,11 +207,29 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 reports current for every supported build', async () => {
|
||||
for (const version of SUPPORTED_GAME_VERSIONS) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
|
||||
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a client that sends no build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/islandedversions is empty', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/islandedversions`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/v2/get returns empty array for a player with none', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/v2/get`, {
|
||||
headers: await bearer('99999'),
|
||||
@@ -250,23 +278,76 @@ 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,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// A fixed list, in render order — the client shows the buttons in the order they
|
||||
// arrive, so the order is part of the contract, not just the contents.
|
||||
test('GET /api/PlayerReporting/v1/voteToKickReasons serves the reasons in order', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/voteToKickReasons`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{ Reason: 'Discriminatory language', ReportCategory: 102 },
|
||||
{ Reason: 'Discriminatory behavior', ReportCategory: 102 },
|
||||
{ Reason: 'Threats or encouraging suicide', ReportCategory: 102 },
|
||||
{ Reason: 'Toxic behavior', ReportCategory: 102 },
|
||||
{ Reason: 'Sexual behavior in public', ReportCategory: 101 },
|
||||
{ Reason: 'Sexual language in public', ReportCategory: 101 },
|
||||
{ Reason: 'Non-consensual sexual behavior', ReportCategory: 101 },
|
||||
{ Reason: 'Player in walls or floor', ReportCategory: 103 },
|
||||
{ Reason: 'Friendly fire', ReportCategory: 103 },
|
||||
{ Reason: 'Microphone spam', ReportCategory: 103 },
|
||||
{ Reason: 'Abusing bugs or exploits', ReportCategory: 103 },
|
||||
{ Reason: 'Spawn camping', ReportCategory: 103 },
|
||||
{ Reason: 'Inactive in games (AFK)', ReportCategory: 6 },
|
||||
{ Reason: 'Prefab swapping', ReportCategory: 6 },
|
||||
{ Reason: 'Not following game rules', ReportCategory: 6 },
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/voteToKickReasons is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/voteToKickReasons`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
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
|
||||
@@ -443,6 +524,189 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
// Nothing locks avatar items here, so the array is empty and the posted ids are never
|
||||
// parsed. Unlike the custom-item bulk below, this one takes no token — the reference
|
||||
// answers outright.
|
||||
test('POST /api/avatar/v1/lockeditems/bulk returns [] without auth', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(['a', 'b']),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
|
||||
// the sibling custom-item reads serve. Nothing stores custom items, so every id
|
||||
// misses, and a miss is an absent entry rather than an error.
|
||||
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer()),
|
||||
},
|
||||
// Repeated form field, as `[FromForm] List<string>` binds it.
|
||||
body: new URLSearchParams([
|
||||
['customAvatarItemIds', 'a'],
|
||||
['customAvatarItemIds', 'b'],
|
||||
]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// The ids are never parsed (nothing could match), so a missing body is still a 200
|
||||
// rather than the 400 a body-reading handler would produce.
|
||||
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/customAvatarItems/v1/bulk is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
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)
|
||||
// The save answers the base envelope — three keys, no `Value`, and NOT the outfit
|
||||
// just sent. Note the mixed casing: `Success`/`Error` but `error_id`.
|
||||
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
|
||||
|
||||
// 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/progressionEvents/active is an empty list (no auth)', async () => {
|
||||
// The client reads an empty list as "no event running" and skips the event UI; a 404
|
||||
// would stall its load instead.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -467,6 +731,12 @@ describe('public endpoints', () => {
|
||||
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('POST /statsigUserProperties returns the StatsigEnabled flag', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/statsigUserProperties`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
})
|
||||
|
||||
test('GET /voice/config returns an object', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/voice/config`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1322,6 +1592,26 @@ describe('public endpoints', () => {
|
||||
).toBe(403)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
|
||||
// A stub: the client renders an empty array as "this creator has published nothing",
|
||||
// where a 404 would read as a row that failed to load.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// The params are accepted and ignored, including a repeated `id` and none at all.
|
||||
expect(
|
||||
await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
|
||||
).json()
|
||||
).toEqual([])
|
||||
expect(
|
||||
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
|
||||
const ids = async (res: Response): Promise<number[]> =>
|
||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
@@ -1425,6 +1715,17 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('account', () => {
|
||||
test.each(['email', 'phone', 'anything'])(
|
||||
'GET /iam/me/channels/%s is an empty list',
|
||||
async (type) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/iam/me/channels/${type}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('auth-gated endpoints', () => {
|
||||
test('401 without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
|
||||
@@ -1747,10 +2048,11 @@ describe('images', () => {
|
||||
// A metadata row was created, and it's readable by name via /api/images/v6.
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
|
||||
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number }
|
||||
expect(meta.ImageName).toBe(ImageName)
|
||||
expect(meta.PlayerId).toBe(42)
|
||||
expect(typeof meta.Id).toBe('number')
|
||||
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
|
||||
expect(typeof meta.SavedImageId).toBe('number')
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
@@ -1820,6 +2122,52 @@ describe('images', () => {
|
||||
expect(await feed('?take=lots')).toBe(10)
|
||||
})
|
||||
|
||||
test('GET /api/images/v5/bulk resolves image records by id, in request order', async () => {
|
||||
const one = await createImage(env.DB, { imageName: 'bulkone.jpg', playerId: 7101 })
|
||||
const two = await createImage(env.DB, { imageName: 'bulktwo.jpg', playerId: 7102 })
|
||||
// Not public: a bulk lookup must not hand this back, or sequential ids would make
|
||||
// every private photo readable by anyone who counts.
|
||||
const hidden = await createImage(env.DB, {
|
||||
imageName: 'bulkhidden.jpg',
|
||||
playerId: 7103,
|
||||
accessibility: 0,
|
||||
})
|
||||
|
||||
const bulk = async (query: string) =>
|
||||
(await (await exports.default.fetch(`${ORIGIN}/api/images/v5/bulk${query}`)).json()) as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
|
||||
// Request order, not id order — the client lines the answers up with what it asked for.
|
||||
const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
|
||||
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id])
|
||||
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the
|
||||
// SavedImageId/SavedImageType projection the player photo lists serve.
|
||||
expect(both[0]).toMatchObject({
|
||||
Id: two.Id,
|
||||
Type: two.Type,
|
||||
ImageName: 'bulktwo.jpg',
|
||||
PlayerId: 7102,
|
||||
TaggedPlayerIds: [],
|
||||
})
|
||||
|
||||
// An unknown id and a non-public one are absent rather than errors or holes, so the
|
||||
// answer can be shorter than the request.
|
||||
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.Id)).toEqual([
|
||||
one.Id,
|
||||
])
|
||||
|
||||
// No ids at all is an empty array.
|
||||
expect(await bulk('')).toEqual([])
|
||||
|
||||
// More ids than D1 will bind in one query (100) — the lookup has to split.
|
||||
const many = [...Array.from({ length: 130 }, (_, i) => 800000 + i), one.Id, two.Id]
|
||||
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.Id)).toEqual([
|
||||
one.Id,
|
||||
two.Id,
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||
// Seed an image to cheer.
|
||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||
@@ -1868,6 +2216,68 @@ describe('images', () => {
|
||||
expect(await cheerCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET|PUT /api/players/v1/playerPhotoTaggingSetting round-trips the preference', async () => {
|
||||
const path = `${ORIGIN}/api/players/v1/playerPhotoTaggingSetting`
|
||||
const read = async (sub: string) => exports.default.fetch(path, { headers: await bearer(sub) })
|
||||
const write = async (sub: string, body: unknown) =>
|
||||
exports.default.fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
// Both are auth-gated.
|
||||
expect((await exports.default.fetch(path)).status).toBe(401)
|
||||
expect((await exports.default.fetch(path, { method: 'PUT' })).status).toBe(401)
|
||||
|
||||
// A player who has never set one reads 0 — a bare integer, not an envelope.
|
||||
const initial = await read('710')
|
||||
expect(initial.status).toBe(200)
|
||||
expect(await initial.text()).toBe('0')
|
||||
|
||||
// The PUT answers the stored value, and the GET agrees afterwards.
|
||||
expect(await (await write('710', { Setting: 2 })).text()).toBe('2')
|
||||
expect(await (await read('710')).text()).toBe('2')
|
||||
|
||||
// It's stored under `playerPhotoTaggingSetting` in the player's settings bag...
|
||||
const stored = await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
'player:710',
|
||||
'json'
|
||||
)
|
||||
expect(stored?.playerPhotoTaggingSetting).toBe('2')
|
||||
|
||||
// ...and the write MERGES: the player's other settings survive it.
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
'player:711',
|
||||
JSON.stringify({ 'Recroom.OOBE': '77', playerPhotoTaggingSetting: '1' })
|
||||
)
|
||||
expect(await (await read('711')).text()).toBe('1')
|
||||
await write('711', { Setting: 0 })
|
||||
expect(
|
||||
await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>('player:711', 'json')
|
||||
).toEqual({ 'Recroom.OOBE': '77', playerPhotoTaggingSetting: '0' })
|
||||
|
||||
// The setting is per-player.
|
||||
expect(await (await read('710')).text()).toBe('2')
|
||||
|
||||
// A body with no readable Setting leaves the stored value alone rather than writing 0
|
||||
// — and answers what the player still has.
|
||||
expect(await (await write('710', { Nothing: true })).text()).toBe('2')
|
||||
expect(await (await read('710')).text()).toBe('2')
|
||||
|
||||
// A form body and the lowercase spelling both parse, as does a numeric string.
|
||||
const form = await exports.default.fetch(path, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer('710')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ setting: '3' }).toString(),
|
||||
})
|
||||
expect(await form.text()).toBe('3')
|
||||
expect(await (await read('710')).text()).toBe('3')
|
||||
})
|
||||
|
||||
test('GET /api/images/v5/cheered/bulk reports per-id cheer state for the caller (auth-gated)', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'bulkcheer.jpg', playerId: 701 })
|
||||
const other = 999999
|
||||
@@ -1907,6 +2317,72 @@ describe('images', () => {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(await empty.json()).toEqual([])
|
||||
|
||||
// The client POSTs the ids as a form body of repeated `id` fields — a photo grid asks
|
||||
// about a whole page at once, far more than belongs in a URL. Same answer as the GET.
|
||||
const posted = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('42')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `id=${img.Id}&id=${other}`,
|
||||
})
|
||||
expect(posted.status).toBe(200)
|
||||
expect(await posted.json()).toEqual([
|
||||
{ SavedImageId: img.Id, IsCheered: true },
|
||||
{ SavedImageId: other, IsCheered: false },
|
||||
])
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, { method: 'POST' }))
|
||||
.status
|
||||
).toBe(401)
|
||||
|
||||
// A full page of ids: D1 caps a query at 100 bound parameters and the player id takes
|
||||
// one, so the lookup has to split rather than fail — the client really does send ~100.
|
||||
const page = [img.Id, ...Array.from({ length: 120 }, (_, i) => 900000 + i)]
|
||||
const fullPage = await exports.default.fetch(`${ORIGIN}/api/images/v5/cheered/bulk`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('42')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: page.map((imageId) => `id=${imageId}`).join('&'),
|
||||
})
|
||||
expect(fullPage.status).toBe(200)
|
||||
const entries = (await fullPage.json()) as Array<{ SavedImageId: number; IsCheered: boolean }>
|
||||
// One entry per requested id, in request order, and the cheer still resolves from the
|
||||
// far side of the split.
|
||||
expect(entries).toHaveLength(page.length)
|
||||
expect(entries.map((e) => e.SavedImageId)).toEqual(page)
|
||||
expect(entries[0]).toEqual({ SavedImageId: img.Id, IsCheered: true })
|
||||
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => {
|
||||
const img = await createImage(env.DB, {
|
||||
imageName: 'v6shape.jpg',
|
||||
playerId: 7301,
|
||||
// No room, no event, no description: all three are null on the row.
|
||||
})
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
SavedImageId: img.Id,
|
||||
ImageName: 'v6shape.jpg',
|
||||
PlayerId: 7301,
|
||||
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
|
||||
RoomId: 0,
|
||||
PlayerEventId: 0,
|
||||
ClubId: 0,
|
||||
Description: '',
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
SavedImageType: 1,
|
||||
CreatedAt: img.CreatedAt,
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
@@ -1940,18 +2416,35 @@ describe('images', () => {
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as {
|
||||
Type: number
|
||||
SavedImageType: number
|
||||
RoomId: number
|
||||
Accessibility: number
|
||||
PlayerEventId: number
|
||||
ClubId: number
|
||||
Description: string
|
||||
}
|
||||
expect(meta.SavedImageType).toBe(1)
|
||||
expect(meta.RoomId).toBe(777)
|
||||
expect(meta.Accessibility).toBe(2)
|
||||
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
|
||||
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
|
||||
// null, and the club (which nothing here sets) reads 0 too.
|
||||
expect(meta).not.toHaveProperty('TaggedPlayerIds')
|
||||
expect(meta.PlayerEventId).toBe(0)
|
||||
expect(meta.ClubId).toBe(0)
|
||||
expect(meta.Description).toBe('')
|
||||
|
||||
// The tagged players and the null event id, as actually stored.
|
||||
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
.bind(ImageName)
|
||||
.first<{ data: string }>()
|
||||
const stored = JSON.parse(row!.data) as {
|
||||
TaggedPlayerIds: number[]
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
expect(meta.Type).toBe(1)
|
||||
expect(meta.RoomId).toBe(777)
|
||||
expect(meta.Accessibility).toBe(2)
|
||||
expect(meta.TaggedPlayerIds).toEqual([5, 6])
|
||||
// playerEventId 0 means "none" → stored as null.
|
||||
expect(meta.PlayerEventId).toBeNull()
|
||||
expect(stored.TaggedPlayerIds).toEqual([5, 6])
|
||||
// playerEventId 0 means "none" → stored as null, and serialized back out as 0.
|
||||
expect(stored.PlayerEventId).toBeNull()
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => {
|
||||
@@ -2533,6 +3026,95 @@ describe('relationships', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('friend online count', () => {
|
||||
// Presence is written by the `match` worker, so it's seeded straight into the table
|
||||
// here. `roomInstance` null is lobby presence — signed in, not in a room.
|
||||
async function setPresence(accountId: number, secondsLeft = PRESENCE_TTL_SECONDS) {
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: null,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 0,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt: Math.floor(Date.now() / 1000) + secondsLeft,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
async function friendOnlineCount(sub: string): Promise<number> {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v1/friendOnlineStatus`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; value: { FriendsOnlineCount: number } }
|
||||
expect(body.success).toBe(true)
|
||||
return body.value.FriendsOnlineCount
|
||||
}
|
||||
|
||||
// Make `a` and `b` friends the way the client does.
|
||||
async function befriend(a: string, b: number) {
|
||||
await exports.default.fetch(`${ORIGIN}/api/relationships/v2/addfriend?id=${b}`, {
|
||||
headers: await bearer(a),
|
||||
})
|
||||
}
|
||||
|
||||
test('POST /api/messages/v1/friendOnlineStatus is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v1/friendOnlineStatus`, {
|
||||
method: 'POST',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('counts only friends who are online, from either side of the row', async () => {
|
||||
// 900 is friends with 901 (as requester) and with 902 (as target).
|
||||
await befriend('900', 901)
|
||||
await befriend('902', 900)
|
||||
// A pending request and a stranger, both online — neither is a friendship.
|
||||
await exports.default.fetch(`${ORIGIN}/api/relationships/v2/sendfriendrequest?id=903`, {
|
||||
headers: await bearer('900'),
|
||||
})
|
||||
|
||||
expect(await friendOnlineCount('900')).toBe(0)
|
||||
|
||||
// Both friends online, plus noise: the caller themselves, the pending request, and
|
||||
// an unrelated player.
|
||||
await setPresence(900)
|
||||
await setPresence(901)
|
||||
await setPresence(902)
|
||||
await setPresence(903)
|
||||
await setPresence(904)
|
||||
expect(await friendOnlineCount('900')).toBe(2)
|
||||
|
||||
// One friend goes offline; the other still counts.
|
||||
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(901).run()
|
||||
expect(await friendOnlineCount('900')).toBe(1)
|
||||
})
|
||||
|
||||
test('expired presence does not count, and unfriending drops the count', async () => {
|
||||
await befriend('910', 911)
|
||||
await setPresence(911, -1)
|
||||
expect(await friendOnlineCount('910')).toBe(0)
|
||||
|
||||
await setPresence(911)
|
||||
expect(await friendOnlineCount('910')).toBe(1)
|
||||
|
||||
await exports.default.fetch(`${ORIGIN}/api/relationships/v2/removefriend?id=911`, {
|
||||
headers: await bearer('910'),
|
||||
})
|
||||
expect(await friendOnlineCount('910')).toBe(0)
|
||||
})
|
||||
|
||||
test('a player with no relationships counts zero', async () => {
|
||||
expect(await friendOnlineCount('920')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages', () => {
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Sent = {
|
||||
@@ -3594,12 +4176,14 @@ describe('openapi', () => {
|
||||
'GET /api/images/v3/feed/player/{playerId}',
|
||||
'GET /api/images/v4/player/{playerId}',
|
||||
'GET /api/images/v4/room/{roomId}',
|
||||
'GET /api/images/v5/bulk',
|
||||
'GET /api/images/v5/cheered/bulk',
|
||||
'GET /api/images/v5/player/{playerId}',
|
||||
'GET /api/images/v6',
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/fromcreators',
|
||||
'GET /api/inventions/v1/fulllineageowner',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
'GET /api/inventions/v1/room',
|
||||
@@ -3628,9 +4212,12 @@ describe('openapi', () => {
|
||||
'GET /api/playerevents/v1/tagfilters',
|
||||
'GET /api/playerevents/v1/{eventId}',
|
||||
'GET /api/playerevents/v1/{eventId}/responses',
|
||||
'GET /api/players/v1/playerPhotoTaggingSetting',
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/progressionEvents/active',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/referee/files',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
'GET /api/relationships/v1/favorite',
|
||||
'GET /api/relationships/v1/ignore',
|
||||
@@ -3646,19 +4233,30 @@ describe('openapi', () => {
|
||||
'GET /api/roomkeys/v1/mine',
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/islandedversions',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /iam/me/channels/{type}',
|
||||
'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/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'POST /api/images/v5/cheered/bulk',
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/messages/v1/friendOnlineStatus',
|
||||
'POST /api/messages/v1/sendMultiple',
|
||||
'POST /api/messages/v2/send',
|
||||
'POST /api/playerReputation/v1/bulk',
|
||||
@@ -3685,6 +4283,9 @@ describe('openapi', () => {
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
'PUT /api/players/v1/playerPhotoTaggingSetting',
|
||||
'PUT /outfits/me',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — an undescribed one renders as a bare path.
|
||||
|
||||
@@ -369,5 +369,18 @@
|
||||
"MicSpamSamplePercentageForForceMuteToEnd": 0.2,
|
||||
"MicSpamWarningStateVolumeMultiplier": 0.25
|
||||
},
|
||||
"ShareBaseUrl": "https://www.rec.example.com/{0}"
|
||||
"ShareBaseUrl": "https://www.rec.example.com/{0}",
|
||||
"StorefrontConfig": {
|
||||
"MinPlayerLevelForGifting": 5,
|
||||
"LatestStoreBadgeDateTime": "2020-01-01T00:00:00Z"
|
||||
},
|
||||
"ConfigTable": [
|
||||
{ "Key": "Gift.DropChance", "Value": "1" },
|
||||
{ "Key": "Gift.XP", "Value": "0.5" }
|
||||
],
|
||||
"PhotonConfig": {
|
||||
"CloudRegion": "us",
|
||||
"CrcCheckEnabled": false,
|
||||
"EnableServerTracingAfterDisconnect": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,16 @@
|
||||
"bucket_name": "recflare-cdn"
|
||||
}
|
||||
],
|
||||
// Per-player settings KV, owned by the `playersettings` worker. Read AND written here,
|
||||
// for the photo-tagging preference (`/api/players/v1/playerPhotoTaggingSetting`), which
|
||||
// is one key in that same bag rather than storage of its own. The "local" id placeholder
|
||||
// is replaced with the real id from RECFLARE_KV at deploy time.
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "RECFLARE_PLAYER_SETTINGS",
|
||||
"id": "local"
|
||||
}
|
||||
],
|
||||
// Cross-worker binding to the SignalR notifications hub DO (owned/migrated by
|
||||
// the `notify` worker). We only invoke its RPC methods; no migration here.
|
||||
"durable_objects": {
|
||||
|
||||
Reference in New Issue
Block a user