mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
14 KiB
14 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
- Use tabs for indentation, spaces for alignment
- Type imports use `import type`
- Workspace imports use `@repo/` prefix
- Import order: Built-ins → Third-party → `@repo/` → Relative
- Prefix unused variables with `_`
- Prefer `const` over `let`
- Use `array-simple` notation
- Explicit function return types are optional
Response shapes the Rec Room client depends on. These were found by watching the live
client, not by reading a spec: when one is wrong the client renders nothing or hangs
rather than erroring, so tests won't catch a regression. Don't "clean up" an
inconsistency here without checking the client first.
- 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. - 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. - 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.