mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
at least we are in the dorm now
This commit is contained in:
+83
-7
@@ -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<App>): Promise<number[]> {
|
||||
.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<App>): 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<App>({ 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<App>({ 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<App>({ 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<Record<string, unknown>>().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<App>({ 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<App>({ 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)]))
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -58,6 +58,10 @@ const app = new Hono<App>()
|
||||
])
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -61,4 +61,11 @@ const app = new Hono<App>()
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<App>()
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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<App>()
|
||||
.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<App>()
|
||||
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<string, unknown>)
|
||||
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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -124,4 +124,35 @@ const app = new Hono<App>()
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user