diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index d8745cd..bcbfaa1 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import apiConfigV2 from '../static/api-config-v2.json' +import defaultAvatar from '../static/default-avatar.json' import gameConfigsV1All from '../static/gameconfigs-v1-all.json' import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' @@ -56,6 +57,18 @@ async function parseFormIds(c: Context): Promise { .filter((n) => !Number.isNaN(n)) } +/** Read integer ids from repeated/comma-separated `id` query params. The 2023 + * client passes these to the bulk GET endpoints (e.g. `?id=1&id=2`). */ +function queryIds(c: Context): number[] { + return ( + c.req + .queries('id') + ?.flatMap((v) => v.split(',')) + .map((s) => Number.parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n)) ?? [] + ) +} + /** Unity scene id for the dorm (matches the match worker's instance location). */ const DORM_SCENE_ID = '76d98498-60a1-430c-ab76-b54a29b7a163' @@ -114,6 +127,40 @@ function buildRoomResponse(roomId: number) { } } +/** + * Photon access-token response (`/roomserver/photon_access_token`). The 2023 + * client calls this to get its room permissions + the instance id it's spawning + * into; a 404 here leaves the player stuck on a black screen. Mirrors the FemRec + * reference (`PhotonAccessToken` is empty — the client uses its baked-in Photon + * credentials). Our synthesized instances always use roomInstanceId 1. + */ +function photonAccessToken() { + const perm = (Permission: string, Role: number, Override: boolean) => ({ + Override, + Permission, + Role, + Type: 0, + Value: 'True', + }) + return { + Permissions: [ + perm('CAN_USE_ROOM_RESET_BUTTON', 0, true), + perm('CAN_USE_DELETE_ALL_BUTTON', 0, true), + perm('CAN_SAVE_INVENTIONS', 0, true), + perm('CAN_SPAWN_INVENTIONS', 0, true), + perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 0, true), + perm('CAN_USE_MAKER_PEN', 30, false), + perm('CAN_USE_ROOM_RESET_BUTTON', 30, true), + perm('CAN_USE_DELETE_ALL_BUTTON', 30, true), + perm('CAN_SAVE_INVENTIONS', 30, true), + perm('CAN_SPAWN_INVENTIONS', 30, true), + perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true), + ], + PhotonAccessToken: '', + RoomInstanceId: 1, + } +} + /** Default reputation for an account — the fallback the C# fills with no DB. */ function defaultReputation(id: number) { return { @@ -204,6 +251,8 @@ const app = new Hono({ strict: false }) const ids = await parseFormIds(c) return c.json(ids.map(defaultReputation)) }) + // The 2023 client calls this as a GET with repeated `id` query params. + .get('/api/playerReputation/v2/bulk', (c) => c.json(queryIds(c).map(defaultReputation))) .post('/api/players/v1/progression/bulk', async (c) => { await parseFormIds(c) // TODO: query PlayerProgressions for these ids return c.json([]) @@ -213,6 +262,11 @@ const app = new Hono({ strict: false }) await parseFormIds(c) // TODO: query PlayerProgressions for these ids return c.json([]) }) + // The 2023 client calls this as a GET with repeated `id` query params (the + // FemRec reference). Return a default progression per requested id. + .get('/api/players/v2/progression/bulk', (c) => + c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 }))) + ) .post('/api/v1/progression/bulk', async (c) => { await parseFormIds(c) // TODO: query PlayerProgressions for these ids return c.json([]) @@ -228,21 +282,23 @@ const app = new Hono({ strict: false }) .get('/api/avatar/v2', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) - // TODO: load/create PlayerAvatar for `id`. - return c.json({ OutfitSelections: '', FaceFeatures: '{}', SkinColor: '', HairColor: '' }) + // TODO: load/create PlayerAvatar for `id`. Must return a populated outfit — + // the client NREs on an empty OutfitSelections — so serve a valid default. + return c.json(defaultAvatar) }) .post('/api/avatar/v2/set', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) const update = await c.req.json>().catch(() => null) if (update === null) return c.body(null, 400) - // TODO: persist; echo the accepted avatar back like the C# does. + // TODO: persist; echo the accepted avatar back like the C# does. Fall back to + // the valid default avatar fields when the client omits them. return c.json({ OwnerAccountId: id, - OutfitSelections: update.OutfitSelections ?? '', - FaceFeatures: update.FaceFeatures ?? '{}', - SkinColor: update.SkinColor ?? '', - HairColor: update.HairColor ?? '', + OutfitSelections: update.OutfitSelections ?? defaultAvatar.OutfitSelections, + FaceFeatures: update.FaceFeatures ?? defaultAvatar.FaceFeatures, + SkinColor: update.SkinColor ?? defaultAvatar.SkinColor, + HairColor: update.HairColor ?? defaultAvatar.HairColor, }) }) .get('/api/avatar/v3/saved', async (c) => { @@ -312,6 +368,24 @@ const app = new Hono({ strict: false }) // No reference shape, so return an empty object until the client needs fields. .get('/voice/config', (c) => c.json({})) + // ---- 2023 client loading-path endpoints (from the FemRec reference) -------- + // NUX checklist + saved inventions — empty lists with no DB. + .get('/api/checklist/v1/current', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) + }) + .get('/api/inventions/v2/mine', (c) => c.json([])) + + // Text sanitization (display names, room names, chat). `v1` echoes the input + // value back; `isPure` reports the text is clean. The client sanitizes text + // during load/display, so a 404 here can stall room entry. + .post('/api/sanitize/v1', async (c) => { + const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown }) + return c.json(typeof body.Value === 'string' ? body.Value : '') + }) + .post('/api/sanitize/v1/isPure', (c) => c.json({ IsPure: true })) + // ---- Player reporting ----------------------------------------------------- .get('/api/PlayerReporting/v1/moderationBlockDetails', (c) => c.json({ @@ -433,6 +507,8 @@ const app = new Hono({ strict: false }) .filter((n) => !Number.isNaN(n)) return c.json(ids.map(buildRoomResponse)) }) + // Photon access token + room permissions the client needs to spawn into a room. + .get('/roomserver/photon_access_token', (c) => c.json(photonAccessToken())) .get('/roomserver/rooms/hot', (c) => c.json({ Results: [], TotalResults: 0 })) .get('/roomserver/roomsandplaylists/hot', (c) => c.json({ Results: [], TotalResults: 0 })) .get('/roomserver/rooms/createdby/me', (c) => c.json([buildRoomResponse(1)])) diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index dd2d8c7..d36e1b3 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -78,6 +78,14 @@ describe('public endpoints', () => { expect(await res.json()).toMatchObject({ AccountId: 99, CheerCredit: 20 }) }) + test('GET /api/playerReputation/v2/bulk?id= returns a reputation per id', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk?id=1&id=2`) + expect(res.status).toBe(200) + const reps = (await res.json()) as Array<{ AccountId: number; CheerCredit: number }> + expect(reps.map((r) => r.AccountId)).toEqual([1, 2]) + expect(reps[0]).toMatchObject({ CheerCredit: 20 }) + }) + test('POST /api/playerReputation/v2/bulk returns a reputation per id', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/playerReputation/v2/bulk`, { method: 'POST', @@ -102,6 +110,14 @@ describe('public endpoints', () => { expect(await res.json()).toBe(false) }) + test('GET /api/players/v2/progression/bulk?id= returns progression per id', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk?id=1&id=2`) + expect(res.status).toBe(200) + const body = (await res.json()) as Array<{ PlayerId: number; Level: number }> + expect(body.map((p) => p.PlayerId)).toEqual([1, 2]) + expect(body[0]).toMatchObject({ Level: 1, XP: 0 }) + }) + test('POST /api/players/v2/progression/bulk returns an array', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, { method: 'POST', @@ -137,6 +153,26 @@ describe('public endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({}) }) + + test('GET /api/inventions/v2/mine returns []', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => { + const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Value: 'hello world' }), + }) + expect(san.status).toBe(200) + expect(await san.json()).toBe('hello world') + + const pure = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { method: 'POST' }) + expect(pure.status).toBe(200) + expect(await pure.json()).toEqual({ IsPure: true }) + }) }) describe('auth-gated endpoints', () => { @@ -172,7 +208,18 @@ describe('auth-gated endpoints', () => { test('GET /api/avatar/v2 returns a default avatar', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, { headers: await bearer() }) - expect(await res.json()).toMatchObject({ FaceFeatures: '{}' }) + const body = (await res.json()) as { OutfitSelections: string } + expect(body.OutfitSelections.length).toBeGreaterThan(0) + }) + + test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`) + expect(anon.status).toBe(401) + const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, { + headers: await bearer(), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) }) }) @@ -190,6 +237,19 @@ describe('room server', () => { expect(rooms[0].SubRooms).toHaveLength(1) }) + test('GET /roomserver/photon_access_token returns permissions + instance id', async () => { + const res = await exports.default.fetch(`${ORIGIN}/roomserver/photon_access_token`) + expect(res.status).toBe(200) + const body = (await res.json()) as { + Permissions: unknown[] + PhotonAccessToken: string + RoomInstanceId: number + } + expect(Array.isArray(body.Permissions)).toBe(true) + expect(body.Permissions.length).toBeGreaterThan(0) + expect(body).toMatchObject({ PhotonAccessToken: '', RoomInstanceId: 1 }) + }) + test('GET /roomserver/rooms/hot returns an empty result set', async () => { const res = await exports.default.fetch(`${ORIGIN}/roomserver/rooms/hot`) expect(await res.json()).toEqual({ Results: [], TotalResults: 0 }) diff --git a/apps/api/static/default-avatar.json b/apps/api/static/default-avatar.json new file mode 100644 index 0000000..440057e --- /dev/null +++ b/apps/api/static/default-avatar.json @@ -0,0 +1,6 @@ +{ + "OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1", + "FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}", + "SkinColor": "Xac-W_R330KfOz-pQla9qg", + "HairColor": "0e_jaaObREWTf1AorAZ95g" +} diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 27481da..b08418f 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -58,6 +58,10 @@ const app = new Hono() ]) }) + // Bulk cached-login lookup by platform id (friends resolution). The client + // POSTs repeated `id=` params on the auth host; no DB → no matches → []. + .post('/cachedlogin/forplatformids', (c) => c.json([])) + // OAuth token endpoint — accepts a form-urlencoded body and issues a JWT. .post('/connect/token', async (c) => { // The C# reads `grant_type`, `account_id`, `platform_id` and `platform` from diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index fd678ec..0973655 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -99,6 +99,16 @@ describe('auth worker routes', () => { expect(payload.platform).toBe('Steam') }) + test('POST /cachedlogin/forplatformids returns []', async () => { + const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'id=76561197971551621&id=76561197976728738', + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + test('GET /role/developer/:id returns ok', async () => { const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`) expect(res.status).toBe(200) diff --git a/apps/clubs/src/clubs.app.ts b/apps/clubs/src/clubs.app.ts index 6b3d152..a6c52c5 100644 --- a/apps/clubs/src/clubs.app.ts +++ b/apps/clubs/src/clubs.app.ts @@ -61,4 +61,11 @@ const app = new Hono() // deserializes this into an object, so it must return `{}` (not `[]`). .get('/subscription/details/:subscription', (c) => c.json({})) + // The player's clubs that have unread announcements (MyClubsWithUnread- + // Announcements). No DB → empty list. + .get('/announcements/v2/mine/unread', (c) => c.json([])) + + // The clubs the player is a member of (GetMyMembershipClubs). No DB → empty. + .get('/club/mine/member', (c) => c.json([])) + export default app diff --git a/apps/clubs/src/test/integration/api.test.ts b/apps/clubs/src/test/integration/api.test.ts index 58fdabe..1ea46c6 100644 --- a/apps/clubs/src/test/integration/api.test.ts +++ b/apps/clubs/src/test/integration/api.test.ts @@ -56,6 +56,18 @@ describe('clubs endpoints', () => { expect(await res.json()).toEqual({}) }) + test('GET /announcements/v2/mine/unread returns []', async () => { + const res = await exports.default.fetch(`${ORIGIN}/announcements/v2/mine/unread`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + + test('GET /club/mine/member returns []', async () => { + const res = await exports.default.fetch(`${ORIGIN}/club/mine/member`) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + test('unknown routes 404', async () => { const res = await exports.default.fetch(`${ORIGIN}/nope`) expect(res.status).toBe(404) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index e159b23..115a37b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -3,6 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' +import defaultAvatar from '../static/default-avatar.json' import defaultAvatarItems from '../static/default-avatar-items.json' import myProgress from '../static/my-progress.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' @@ -87,7 +88,17 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(c) // TODO: load/create the PlayerAvatar for `id` once a DB binding exists. - return c.json({ OutfitSelections: '', FaceFeatures: '{}', SkinColor: '', HairColor: '' }) + // Must return a populated outfit — the client's parser NREs on an empty + // OutfitSelections (real RecNet never returns one), so serve a valid default. + return c.json(defaultAvatar) + }) + + // NUX checklist — the client fetches this on the econ host during load. [] + // with no DB. A 404 here can abort the load orchestration before matchmake. + .get('/api/checklist/v1/current', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + return c.json([]) }) // The player's saved outfits. [Authorize]; empty without a DB binding. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 176ebcf..0b465ee 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -72,15 +72,14 @@ describe('econ endpoints', () => { expect(res.status).toBe(401) }) - test('GET /api/avatar/v2 returns the default avatar with a valid token', async () => { + test('GET /api/avatar/v2 returns a populated default avatar with a valid token', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2`, { headers: await bearer() }) expect(res.status).toBe(200) - expect(await res.json()).toEqual({ - OutfitSelections: '', - FaceFeatures: '{}', - SkinColor: '', - HairColor: '', - }) + const body = (await res.json()) as { OutfitSelections: string; FaceFeatures: string } + // Must be non-empty — the client's outfit parser NREs on an empty string. + expect(body.OutfitSelections.length).toBeGreaterThan(0) + expect(body.OutfitSelections).toContain(';') + expect(body.FaceFeatures).toContain('eyeId') }) test('GET /econ/customAvatarItems/v1/owned returns { items: [] } (no auth)', async () => { @@ -97,6 +96,16 @@ describe('econ endpoints', () => { expect(Array.isArray(body.ObjectiveGroups)).toBe(true) }) + test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`) + expect(anon.status).toBe(401) + const res = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`, { + headers: await bearer(), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + test('GET /api/avatar/v3/saved 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/avatar/v3/saved`) expect(anon.status).toBe(401) diff --git a/apps/econ/static/default-avatar.json b/apps/econ/static/default-avatar.json new file mode 100644 index 0000000..440057e --- /dev/null +++ b/apps/econ/static/default-avatar.json @@ -0,0 +1,6 @@ +{ + "OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1", + "FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}", + "SkinColor": "Xac-W_R330KfOz-pQla9qg", + "HairColor": "0e_jaaObREWTf1AorAZ95g" +} diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 7a9e9e5..6d9e21d 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -138,6 +138,7 @@ function dormRoomInstance() { eventId: 0, clubId: 0, roomCode: '', + photonRegion: 'us', photonRegionId: 'us', photonRoomId: DORM_PHOTON_ROOM_ID, name: 'DormRoom', @@ -163,6 +164,7 @@ function buildRoomInstance(roomName: string, isPrivate: boolean): RoomInstance { eventId: 0, clubId: 0, roomCode: '', + photonRegion: 'us', photonRegionId: 'us', photonRoomId: crypto.randomUUID(), name: roomName, @@ -189,19 +191,20 @@ const app = new Hono() .notFound(withNotFound()) // ---- Player presence ----------------------------------------------------- - // Login/exclusivelogin: the player isn't in a room yet, so clear any stale - // presence (mirrors the C# connect/token removing the player's RoomInstance). - // The first heartbeat after this reports roomInstance=null until matchmake. - .post('/player/login', async (c) => { + // Login/exclusivelogin are no-op acks (matching every reference server). They + // MUST NOT touch presence: the client calls exclusivelogin when going online, + // and clearing here would wipe the room matchmake just stored → empty KV → + // the heartbeat reports no room. Only logout clears presence. + .post('/player/login', (c) => c.body(null, 200)) + .post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 })) + + // Logout: drop the player's presence (they're no longer in a room). Both + // reference servers expose this; returns 200. + .post('/player/logout', async (c) => { const id = await authedId(c) if (id !== null) await c.env.MATCH_PRESENCE.delete(presenceKey(id)) return c.body(null, 200) }) - .post('/player/exclusivelogin', async (c) => { - const id = await authedId(c) - if (id !== null) await c.env.MATCH_PRESENCE.delete(presenceKey(id)) - return c.json({ errorCode: 0 }) - }) .get('/player', async (c) => { // Returns each requested player's presence. The C# reads the `id` query @@ -312,6 +315,18 @@ const app = new Hono() if (id !== null) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) }) + // The 2023 client uses a two-segment matchmake/room/{roomId}. Synthesize the + // room instance and store it as presence. + .post('/matchmake/room/:roomId', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const roomId = c.req.param('roomId') + const body = await c.req.parseBody().catch(() => ({}) as Record) + const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0 + const instance = roomId === '1' ? dormRoomInstance() : buildRoomInstance(roomId, joinMode === 2) + await enterRoom(c, id, instance) + return c.json({ errorCode: 0, roomInstance: instance }) + }) .post('/matchmake/:room', async (c) => { const id = await authedId(c) if (id === null) return unauthorized(c) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 9cecd58..3734109 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -85,6 +85,18 @@ describe('public endpoints', () => { expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/) }) + test('POST /matchmake/room/:roomId synthesizes an instance and stores presence', async () => { + const headers = await bearer('88') + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/42`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ JoinMode: '2' }).toString(), + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { roomInstance: { roomId: number; isPrivate: boolean } } + expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true }) + }) + test('POST /matchmake/none returns the offline dorm', async () => { const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' }) expect(res.status).toBe(200) @@ -244,10 +256,11 @@ describe('auth-gated endpoints', () => { }) }) - test('player/login clears presence (back to not-in-a-room)', async () => { - const headers = await bearer('9') + test('player/logout returns 200 and clears presence', async () => { + const headers = await bearer('77') await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) - await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers }) + const out = await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers }) + expect(out.status).toBe(200) const hb = (await ( await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) ).json()) as { roomInstance: unknown; isOnline: boolean } @@ -255,6 +268,20 @@ describe('auth-gated endpoints', () => { expect(hb.isOnline).toBe(false) }) + test('login/exclusivelogin do NOT clear presence (only logout does)', async () => { + const headers = await bearer('9') + await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers }) + // The client calls exclusivelogin when going online — it must not wipe the + // room matchmake just stored. + await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST', headers }) + await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers }) + const hb = (await ( + await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) + ).json()) as { roomInstance: { name: string } | null; isOnline: boolean } + expect(hb.isOnline).toBe(true) + expect(hb.roomInstance?.name).toBe('DormRoom') + }) + test('GET /player?id reports stored presence per id', async () => { await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 3e9a449..9b36baf 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -124,4 +124,35 @@ const app = new Hono() // Rooms created by the caller. The C# serves JSON/ownedrooms.json (the dorm). .get('/roomserver/rooms/createdby/me', (c) => c.json([buildRoomResponse(1)])) + // Photon access token + room permissions the client needs to spawn into a + // room. Without it the player is stuck on a black screen. PhotonAccessToken is + // empty (the client uses its baked-in Photon credentials); roomInstanceId is + // our constant 1. + .get('/roomserver/photon_access_token', (c) => { + const perm = (Permission: string, Role: number, Override: boolean) => ({ + Override, + Permission, + Role, + Type: 0, + Value: 'True', + }) + return c.json({ + Permissions: [ + perm('CAN_USE_ROOM_RESET_BUTTON', 0, true), + perm('CAN_USE_DELETE_ALL_BUTTON', 0, true), + perm('CAN_SAVE_INVENTIONS', 0, true), + perm('CAN_SPAWN_INVENTIONS', 0, true), + perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 0, true), + perm('CAN_USE_MAKER_PEN', 30, false), + perm('CAN_USE_ROOM_RESET_BUTTON', 30, true), + perm('CAN_USE_DELETE_ALL_BUTTON', 30, true), + perm('CAN_SAVE_INVENTIONS', 30, true), + perm('CAN_SPAWN_INVENTIONS', 30, true), + perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true), + ], + PhotonAccessToken: '', + RoomInstanceId: 1, + }) + }) + export default app diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 3767861..fcef5da 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -47,6 +47,14 @@ describe('rooms endpoints', () => { expect(res.status).toBe(400) }) + it('GET /roomserver/photon_access_token returns permissions + instance id', async () => { + const res = await SELF.fetch(`${ORIGIN}/roomserver/photon_access_token`) + expect(res.status).toBe(200) + const body = (await res.json()) as { Permissions: unknown[]; RoomInstanceId: number } + expect(body.Permissions.length).toBeGreaterThan(0) + expect(body.RoomInstanceId).toBe(1) + }) + it('GET /roomserver/rooms/createdby/me returns the owned rooms array', async () => { const res = await SELF.fetch(`${ORIGIN}/roomserver/rooms/createdby/me`) expect(res.status).toBe(200)