mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 07:28:53 -07:00
22 KiB
22 KiB
- `just install` - Install dependencies
- `just dev` - Run development servers (uses `bun runx dev` - context-aware)
- `just test` - Run tests with vitest (uses `bun vitest`)
- `just build` - Build all workers (uses `bun turbo build`)
- `just check` - Check code quality - deps, lint, types, format (uses `bun runx check`)
- `just fix` - Fix code issues - deps, lint, format, workers-types (uses `bun runx fix`)
- `just deploy` - Deploy all workers (uses `bun turbo deploy`)
- `just preview` - Run Workers in preview mode
- `just new-worker` (alias: `just gen`) - Create a new Cloudflare Worker
- `just new-package` - Create a new shared package
- `just update deps` (alias: `just up deps`) - Update dependencies across the monorepo
- `just update pnpm` - Update pnpm version
- `just update turbo` - Update turbo version
- `bun turbo -F worker-name dev` - Start specific worker
- `bun turbo -F worker-name test` - Test specific worker
- `bun turbo -F worker-name deploy` - Deploy specific worker
- `bun vitest path/to/test.test.ts` - Run a single test file
- `pnpm -F @repo/package-name add dependency` - Add dependency to specific package
- Cloudflare Workers monorepo using pnpm workspaces and Turborepo
- `apps/` - Individual Cloudflare Worker applications
- `packages/` - Shared libraries and configurations
- `@repo/oxlint-config` - Shared oxlint configuration
- `@repo/typescript-config` - Shared TypeScript configuration
- `@repo/hono-helpers` - Hono framework utilities
- `@repo/tools` - Development tools and scripts
- Worker apps delegate scripts to `@repo/tools` for consistency
- Hono web framework with helpers in `@repo/hono-helpers`
- Vitest with `@cloudflare/vitest-pool-workers` for testing
- Syncpack ensures dependency version consistency
- Turborepo enables parallel task execution and caching
- Workers configured via `wrangler.jsonc` with environment variables
- Each worker has `context.ts` for typed environment bindings
- Integration tests in `src/test/integration/`
- Workers use `nodejs_compat` compatibility flag
- GitHub Actions deploy automatically on merge to main
- Changesets manage versions and changelogs
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
20230414— default, official. Manifest7859140924515540835(2023).20250718.01— beta, official. Manifest1151455856673601091; reaches this server via the patch-2025 patch.20250424.01,20231207,20230616— alpha.
Builds are date-stamped (YYYYMMDD[.NN]) so they order as plain strings; several
surfaces gate on "newer than 20230414" (econ storefront catalog, api event tags,
rooms featured rooms) rather than on an explicit list.
- Player image lists (
api:/api/images/v5|v4/player/:id,/api/images/v3/feed/player/:id) must use thetoImagesPlayerprojection —Id→SavedImageId,Type→SavedImageType, noTaggedPlayerIds. Serving the rawSavedImagerenders blank thumbnails. - The room photo feed (
api:/api/images/v4/room/:roomId) serves the rawSavedImageand displays correctly. It is deliberately NOT projected — do not unify these two. - A club's
AdditionalImages(clubs) is an array of wholeSavedImagerecords, not image names — a bare string array fails the client's parser ("expected '{'"). The list is packed: removing an image shifts the rest up, never leaving a blank slot. - A room's
LoadScreens(rooms:PUT /rooms/:id/loadscreen) is an array — the client's parser wants one — but the client renders only the FIRST entry and only ever posts one. So the endpoint REPLACES the list rather than appending: an appended screen sits unreachable behind the old one and setting a load screen looks like it did nothing. Keep the array shape for eventual multi-screen support. - Endpoints the client re-renders from must return the updated entity, not
{ error, success, value: null }— e.g.clubsPUT /club/:id/clubhouseleft the old clubhouse on screen until it answered the full details envelope. - Every subroom mutation (
rooms: create, delete,/subrooms/:sid/clone,/subrooms/:sid/accessibility,/subrooms/:sid/publish_save) answers{ success, error, value }with the whole updated ROOM — the client re-renders the room fromvalue. Notablyvalueis the room even forclone, whose product is a new SUBROOM; only the room-levelPOST /rooms/:id/clonereturns the thing it created. - The room save (
rooms:POST /subrooms/:sid/data) is the ONE exception to that shape:valueis{ room, subRoomDataSave }, anderroris NULL rather than"". ThesubRoomDataSaveis camelCase with a different field set from the PascalCaseCurrentSaveembedded in the room (no persistence/OM/UGC versions, no moderation state, no asset arrays; butunityAsset/unityAssetHash). Don't unify the two projections. - A subroom's saved scene loads from
CurrentSave.DataBlob(rooms:GET /rooms/:id), NOT the flatDataBlobon the subroom — a subroom with noCurrentSavesilently loads nothing. The key must be present (null before the first publish); read it viasubRoomDataBlob()somatch/authinstance payloads resolve it the same way. - A room save (
rooms:POST …/subrooms/:sid/data) publishes only when the body saysAutoPublish: true; otherwise it STAGES ontoStagedSubRoomDataSaveIdand leavesCurrentSavealone, so players keep loading the last published version until the owner posts…/subrooms/:sid/publish_savewithsubRoomDataSaveId=<id>. DORMS always publish: no publish step exists in the client for them. Saves live in thesubroom_savetable with globally-unique ids (a bare id has to resolve —StagedSubRoomDataSaveIdcarries no subroom context), and nothing is overwritten, so…/savesis real history andpublish_savedoubles as restore-a-save. There is noGET …/subrooms/:sid/data; only the POST (the room save) exists on that path.GET …/saves/:saveIdis the detail behind a list row, under the same gate, but in the CAMELCASE projection the room save's response uses — not the PascalCase rows the list serves. Three shapes of one save; keep them straight. - Both save reads (
rooms:…/savesand…/saves/:saveId) are auth-gated and readable by the room's CREATOR or by anyone whose livepresencerow puts them in that room — not by co-owners as such (a co-owner passes only by standing there). They list unpublished staged saves, so they aren't public; but a visitor resolves which version an instance is running from this list, so creator-only locks them out of loading the room. The grant expires with the presence row. - A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision:
Descriptionis the save comment shown in…/saves, andPersistenceVersion/InventionUsagedescribe the scene just saved (the latter lives on the SUBROOM). The room's public description isPUT /rooms/:id/description's alone; copying the save comment ontoroom.Description(as this once did) silently replaces the room's description every time someone saves. - Matchmaking (
match:/matchmake/room/:roomId/:subRoomId) always serves the PUBLISHEDCurrentSaveblob, creator included. Joining a private instance, the client itself asks the owner whether to load the latest or the published version and resolves it from the/subrooms/:sid/saveslist — the matchmake call is identical either way. Don't make this server-side: it would put two people in one instance on different versions. - A balance lives in a
(CurrencyType, Platform)BUCKET and the client shows the SUM of the buckets, soPlatformis a balance's identity, not a label. This server uses exactly one bucket per currency —ALL_PLATFORMS, -2NonPurchasedNotUsableInP2P— and every surface must name it: the balance DTO (econ:GET /api/storefronts/v4/balance/:type), theBalanceTypethe storefront bodies echo, and thePlatformon everyStorefrontBalance*socket frame. Two traps, which produced two "balance doubling" bugs that both looked like the frames being additive when they are not:- Each frame SETS the bucket it names to an absolute value —
Balanceis the RESULTING TOTAL, never the change (StorefrontBalancePurchase'sDelta/BalanceAddTypeare display-only; the client logs them and storesBalanceoutright). Send a change and the balance becomes that change. Being absolute, a frame is idempotent: re-sending one, or racing aGET /balance, cannot drift the total, so the player reading the HTTP response for the same change gets a frame too. - The bucket key on the wire is
Platform. The client's property is namedBalanceTypebut carries a[DataMember]rename, and its decoder drops unknown members silently, so a frame sayingBalanceTypelands inPlatform0 (SteamPurchased) and adds a phantom balance to the real one — 10,000 tokens + a 250 reward read 20,250. Sending a real-but- different platform does the same:Platform: RecNeton a buy showed 34,100 to a player who spent 900 of 17,500, then 33,200 once the body's -900 reached the true bucket. The payload shapes are recovered from the client's own decoder inapps/notify/src/notification-payloads.ts— build frames against those interfaces (econ does) so a renamed key fails the build instead of silently vanishing on the wire.
- Each frame SETS the bucket it names to an absolute value —
- Every matchmake response (
match:/matchmake/*, refusals and the ban middleware's included) must echo the request'sCorrelationIdback ascorrelationId— the client tags each attempt with a GUID and fails with "Unable to connect to game session" if it can't match the response to the attempt. A request that names none gets the all-zeroGuid.Emptyrather than null: the client's field is a non-nullable Guid. The code is served under TWO names,result(what the client reads) anderrorCode(what this server has always sent); they are the same number and must never disagree, which is why everything answers throughmatchmakeResultrather than building the envelope by hand. - What PLAYS a cheer on the cheered player's client is a
MessageReceivedframe carrying a Message of type 50PlayerCheer(51PlayerCheerAnonymous,FromPlayerId0, when the body saysAnonymous) withData= the category as a string — the same frame every reference server (meownet-api, DorkNet, E12354) sends.ReputationUpdatealone refreshes the counters and shows nothing: the cheer "worked" server-side and nobody saw it. TheReputationUpdateframes the cheer (api:POST /api/PlayerCheer/v1/create) sends are the RECORD, trimmed —IsCheerful(a profile flag, always true) andSelectedCheer(the cheer pinned viaPOST /api/PlayerCheer/v1/SetSelectedCheer, stored onreputation) come off the row exactly as the DTO serves them. This server once overrode both per frame to "play" the cheer; no reference does, and it played nothing. - A cheer is a thing that happens in FRONT of people, so the
ReputationUpdatenaming the cheered player goes to everyone in the room instance, not just the two players. The cheered player gets it durably (their counters moved); the rest of the room gets it ephemerally. The audience comes from the giver's livepresencerow, NOT the body'sRoomId, which is accepted and unused. NeitherRoomIdnorAnonymousis stored. - The cheer's reply is
{ Success, Message }— PascalCase, withMessageNULL on success. That is NOT the lowercase{ success, error: "" }envelope the reports and warnings use; the two live side by side in the same worker and must not be unified. - A Message's
Data(everyMessageReceivedframe) is a STRING on the wire, so a payload with structure to it goes in ESCAPED —"Data": "{\"PlayerId\":\"205\"}", never a nested object. An object there does not degrade: the client's decoder rejects it outright (expected:'String Begin Token', actual:'{') and loses the whole notification, not just the field. Bites a chat message'sContentsthe same way. - The vote-to-kick frame (
api:POST /api/PlayerReporting/v3/voteToKick) carries the player being VOTED ON as the Message'sFromPlayerId— not the caller, whom nothing on the frame names — because the client raises its prompt about whoever that field names. Sending the voter asks the room to kick the player who called the vote. ItsDatais the postedReasonas PLAIN TEXT (Inactive in games (AFK)), which is the text the prompt shows: it once carried an escaped{ PlayerId, Response, GameSessionId }, and players saw that JSON printed where the reason belongs.Datais still a string on the wire — that part of the rule above holds; a reason simply needs no escaping to satisfy it. - Leaderboard
Rank(leaderboard:GetRanks,GetNearbyScores,GetPlayerRank) is 0-BASED — the client adds one before it draws, so aRankof 1 shows in game as second place and the top of a board must be 0. Its own slice says the same: it asks for the first ten rows asRankStart0,RankEnd9, both inclusive, so reading them as 1-based also serves nine rows starting at the runner-up. The unranked sentinel stays a big number (99999) precisely because 0 is now a real rank, first place. - A custom avatar item (
api:custom_avatar_item, every/api/customAvatarItems/*read) is ONE record for two kinds of thing. A player-made shirt hasBaseAvatarItemId/BaseAvatarItemColorand a design PNG (DesignFilename,ThumbnailImageFilename) and an emptyCurrentSaves; a FIRST-PARTY item (imported from the official export, Coach-authored, its ownOutfitType) has those four NULL and is rendered fromCurrentSaves— one built Unity assetbundle perBodyType, which the client picks by the wearer's body. The row is the record as JSON, so an import is the export verbatim except thatCreatorAccountIdis forced to 1, the Coach — which is what files it as stock content and whom a sale pays — and each save'sThumbnailFileNameis put underavatar/, where this server serves it from (just cai-loadloads it;apps/api/scripts/import-custom-avatar-items.tswrites a migration; re-importing an id replaces it). The export'sPriceis mostly 0, and a purchase charges the record'sPrice, so first-party prices are written into the export from the storefront dump (apps/econ/static/db/Watch_EnumValue_3.json, joined onAvatarItemInfo.DownloadableAvatarItemId) byapps/api/scripts/price-custom-avatar-items.tsbefore loading. Serve both through one shape: the shirt carries emptyCurrentSaves/Tagsand nullCustomBadgeMetadata, never a missing key. The saves name their assetbundles and thumbnails by bare filename; storing the record does nothing about serving those files. - An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
unityAssetTargeton every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the rest unobserved; a query param on the GETs, a FORM field onPOST …/v1/bulk). The row stores the bare PC names; a caller asking for 2 is servedUnityAsset/UnityAsset2underquest/(toQuestCustomAvatarItem), fetched from the cdn as/avatar/quest/<name>.assetbundle. Every read a Quest renders from has to make the switch —apisearch and bulk, ANDeconGET /econ/customAvatarItems/v1/owned, which the client reads FIRST and renders worn items from: with onlyapiswitched the Quest kept downloading PC bundles. The load BLANKS each save'sUnityAssetHash/UnityAsset2Hash("", null stays null): the export's are the PC builds' hashes, one stored save serves both targets, and the client refuses a download that fails the hash it was given but skips the check on an empty one. - Buying a custom avatar item goes through the ordinary bag (
econ:POST /api/items/bulkpurchase) as a line whoseItemPurchaseMethodIdis{ Type: 1, Guid: <CustomAvatarItemId> }, beside theType: 0, NumberIdcatalog lines. It is a SALE between players: the price leaves the buyer in the bag's one debit and is credited to the item'sCreatorAccountId(pushed to them as aStorefrontBalanceUpdatecarrying their resulting total), ownership is a row ininventory_custom, and the entry'sData.CustomAvatarItemis the whole item record. The item still comes in a gift box, like any purchase: itsGiftPackagenames it byCustomAvatarItemIdwithAvatarItemDesc/EquipmentPrefabName/etc. empty — without a box the purchase shows the player nothing.BypassGiftPackagesskips it as it does any line. A custom line may carry aGiftblock: ownership and the box go toToPlayerId(announced withGiftPackageReceivedImmediate), the buyer still pays and the creator is still paid, and the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own.GET /econ/customAvatarItems/v1/ownedis that table joined to the item record PLUS everything the caller created (drafts included): a creator owns their own throughCreatorAccountId, and the bag refuses to sell it to them. - A chat report (
api:POST /api/chatreport/createChatReport) is form-encoded and sendsReportCategoryas an enum NAME (Discriminatory), where every other report sends the number. It names no player: the reported one is the message's sender, read frommessage. The name is mapped onto the numericKickReportCategorythereporttable stores (chatReportCategory); onlyDiscriminatoryhas been observed, so an unmapped name is filed as 0 with the name kept in the details — extend the alias table as more are seen. The reply is the{ success, error: "" }envelope the other reports use, which is an ASSUMPTION here: what the client does with this response has not been observed. - A friend request is SHOWN to the target as a Message of type 4
FriendInvite(api:/api/relationships/v2/sendfriendrequest), and an acceptance to the requester as type 40FriendRequestAccepted(acceptfriendrequest, and the crossing-request auto-accept), each stored and pushed asMessageReceived. TheRelationshipChangedframes only refresh relationship state: sent alone, the request "worked" server-side and the target saw nothing. Same pattern as the cheer. - A gift box's
AvatarItemType(econ: the stored box inGET /api/avatar/v2/gifts,buyItem'sBalanceUpdates[].Data,bulkpurchase'sGiftPackage, and theGiftPackageReceived*frame;www's staff boxes;api'sgifts/generate) must be NULL for anything that is not an avatar item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds one, whether a baked item byAvatarItemDescor a custom item byCustomAvatarItemId. The client routes the box on that field before it reads what else the box names, so a 0 sends it looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists every non-avatar drop withAvatarItemType: nullfor this reason;boxAvatarItemTypeapplies it on every surface, so don't coalesce it with?? 0anywhere a box is built. This holds for every worker that mints a box, not onlyecon. - A ban's
Duration(api:/api/PlayerReporting/v1/moderationBlockDetails, and theModerationKickframewww's staff ban sends) is the seconds LEFT as of the response — the client counts it down from receipt. It is NOT the ban's full span paired withTimeoutStartedAt(start + duration): served that way, the screen read correctly only in the kick frame, sent at the instant the span and the remainder coincide, and every later sign-in showed the whole ban still to run.TimeoutStartedAtstays null for a ban; where other implementations set it at all, it names the start of a voice-chat timeout. Other implementations with timed bans keep their set-time private and serve the remainder on every read;banned_athere is the staff panel's audit trail, not the client's. - Accessibility is sent as the
RoomAccessibilityenum NAME onroomsPUT /rooms/:id/subrooms/:sid/accessibility(accessibility=Private), not the ordinal the room-level/rooms/:id/accessibilitytakes. The enum has five members (Private, Public, Unlisted, Dev_only, Dev_Unlisted); parse viaparseAccessibility, which accepts either form.