mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
custom rooms
This commit is contained in:
@@ -4,6 +4,10 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
|
||||
export type Env = SharedHonoEnv & {
|
||||
// D1 database holding rooms (JSON blob + generated columns). See rooms-db.ts.
|
||||
DB: D1Database
|
||||
// Shared player-presence KV (owned by the `match` worker). Read here to resolve
|
||||
// the caller's current room instance for the photon access token — the
|
||||
// equivalent of the reference server's HeartbeatDB.GetPlayerHeartbeat.
|
||||
RECFLARE_MATCH_PRESENCE: KVNamespace
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -37,6 +37,17 @@ export const SCHEMA_DDL: string[] = [
|
||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||
export type Room = Record<string, unknown>
|
||||
|
||||
/** A room role assignment (the client's RoomRole shape). */
|
||||
interface RoomRole {
|
||||
AccountId: number
|
||||
Role: number
|
||||
LastChangedByAccountId: number | null
|
||||
InvitedRole: number
|
||||
}
|
||||
|
||||
/** Owner role value (max byte) — the room creator's tier. */
|
||||
const ROLE_OWNER = 255
|
||||
|
||||
/**
|
||||
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
||||
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
||||
@@ -64,6 +75,13 @@ export async function cloneRoom(
|
||||
)
|
||||
: source.Tags
|
||||
|
||||
// Ownership is reset to the cloner — the source room's Roles (its creator and
|
||||
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
|
||||
// carry over, or the clone would still list the template's owner as owner.
|
||||
const roles: RoomRole[] = [
|
||||
{ AccountId: accountId, Role: ROLE_OWNER, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
]
|
||||
|
||||
const cloned: Room = {
|
||||
...source,
|
||||
RoomId: newRoomId,
|
||||
@@ -71,6 +89,7 @@ export async function cloneRoom(
|
||||
CreatorAccountId: accountId,
|
||||
IsDorm: false,
|
||||
Tags: tags,
|
||||
Roles: roles,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
|
||||
+60
-20
@@ -56,8 +56,28 @@ function allIds(idParam: string): number[] {
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
}
|
||||
|
||||
/** Room permissions + (empty) Photon token the client needs to spawn into a room. */
|
||||
function photonAccessToken() {
|
||||
/** Account ids granted the global (Role 0) maker pen — the reference server's
|
||||
* hardcoded moderator/dev accounts. */
|
||||
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
||||
|
||||
/** Presence KV key (owned by the `match` worker); keep in sync with match's `presenceKey`. */
|
||||
const presenceKey = (id: number) => `presence:${id}`
|
||||
|
||||
/** The slice of the match worker's presence record we read — the caller's current
|
||||
* room instance. Mirrors the reference server's HeartbeatDB row. */
|
||||
interface PresenceView {
|
||||
roomInstance?: { roomInstanceId?: number } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Room permissions + Photon token the client needs to spawn into a room. The
|
||||
* global (Role 0) maker pen is added only for the hardcoded dev accounts, and
|
||||
* `RoomInstanceId` is the caller's current instance from presence (null when
|
||||
* they aren't in one). `PhotonAccessToken` stays empty — the reference server
|
||||
* signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
|
||||
* Photon setup accepts an empty token.
|
||||
*/
|
||||
function photonAccessToken(accountId: number, roomInstanceId: number | null) {
|
||||
const perm = (Permission: string, Role: number, Override: boolean) => ({
|
||||
Override,
|
||||
Permission,
|
||||
@@ -65,23 +85,43 @@ function photonAccessToken() {
|
||||
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,
|
||||
const 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),
|
||||
]
|
||||
if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
|
||||
permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
|
||||
}
|
||||
return {
|
||||
Permissions: permissions,
|
||||
PhotonAccessToken: '',
|
||||
RoomInstanceId: roomInstanceId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Photon access-token handler (served bare and under `/roomserver`). Auth-gated:
|
||||
* resolves the caller, reads their current room instance from the shared
|
||||
* presence KV, and returns the permissions + token.
|
||||
*/
|
||||
async function handlePhotonAccessToken(c: Context<App>) {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
const presence = await c.env.RECFLARE_MATCH_PRESENCE.get<PresenceView>(
|
||||
presenceKey(accountId),
|
||||
'json'
|
||||
)
|
||||
const roomInstanceId = presence?.roomInstance?.roomInstanceId ?? null
|
||||
return c.json(photonAccessToken(accountId, roomInstanceId))
|
||||
}
|
||||
|
||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||
@@ -436,7 +476,7 @@ const app = new Hono<App>()
|
||||
|
||||
// Photon access token + room permissions the client needs to spawn into a
|
||||
// room. The client calls it on the rooms host both bare and under `/roomserver`.
|
||||
.get('/photon_access_token', (c) => c.json(photonAccessToken()))
|
||||
.get('/roomserver/photon_access_token', (c) => c.json(photonAccessToken()))
|
||||
.get('/photon_access_token', handlePhotonAccessToken)
|
||||
.get('/roomserver/photon_access_token', handlePhotonAccessToken)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -388,6 +388,7 @@ describe('rooms endpoints', () => {
|
||||
Name: string
|
||||
CreatorAccountId: number
|
||||
Tags?: Array<{ Tag: string }>
|
||||
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -401,6 +402,11 @@ describe('rooms endpoints', () => {
|
||||
expect(ok.value!.RoomId).toBeGreaterThan(51)
|
||||
// The `base` template tag is dropped so clones aren't listed as base rooms.
|
||||
expect((ok.value!.Tags ?? []).some((t) => t.Tag === 'base')).toBe(false)
|
||||
// Ownership is reset to the cloner: sole owner (Role 255), and none of the
|
||||
// source base room's roles (accounts 1/2) carry over.
|
||||
expect(ok.value!.Roles).toEqual([
|
||||
{ AccountId: 801, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
])
|
||||
|
||||
// It persists and is fetchable by its new id.
|
||||
const fetched = (await (await SELF.fetch(`${ORIGIN}/rooms/${ok.value!.RoomId}`)).json()) as {
|
||||
@@ -577,12 +583,57 @@ describe('rooms endpoints', () => {
|
||||
expect('allowNewUsers' in (fetched as object)).toBe(false)
|
||||
})
|
||||
|
||||
it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => {
|
||||
it('GET /photon_access_token 401s without a token', async () => {
|
||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /photon_access_token (bare + /roomserver) returns permissions + presence instance', async () => {
|
||||
// Seed the caller's presence so RoomInstanceId reflects their current instance.
|
||||
await env.RECFLARE_MATCH_PRESENCE.put(
|
||||
'presence:777',
|
||||
JSON.stringify({ roomInstance: { roomInstanceId: 1000042 } })
|
||||
)
|
||||
const headers = await bearer('777')
|
||||
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}${path}`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { Permissions: unknown[]; RoomInstanceId: number }
|
||||
expect(body.Permissions.length).toBeGreaterThan(0)
|
||||
const body = (await res.json()) as {
|
||||
Permissions: Array<{ Permission: string; Role: number }>
|
||||
PhotonAccessToken: string
|
||||
RoomInstanceId: number | null
|
||||
}
|
||||
expect(body.Permissions.length).toBe(11)
|
||||
expect(body.RoomInstanceId).toBe(1000042)
|
||||
// A non-dev account does NOT get the global (Role 0) maker pen.
|
||||
expect(
|
||||
body.Permissions.some((p) => p.Permission === 'CAN_USE_MAKER_PEN' && p.Role === 0)
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('GET /photon_access_token returns null RoomInstanceId when the caller has no presence', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer('888') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as { RoomInstanceId: number | null }).RoomInstanceId).toBeNull()
|
||||
})
|
||||
|
||||
it('GET /photon_access_token grants the global maker pen to dev accounts (1/2/3)', async () => {
|
||||
for (const sub of ['1', '2', '3']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/photon_access_token`, { headers: await bearer(sub) })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Permissions: Array<{ Permission: string; Role: number; Override: boolean }>
|
||||
}
|
||||
// The global maker pen is prepended → first entry, Role 0, Override true.
|
||||
expect(body.Permissions[0]).toMatchObject({
|
||||
Permission: 'CAN_USE_MAKER_PEN',
|
||||
Role: 0,
|
||||
Override: true,
|
||||
})
|
||||
expect(body.Permissions.length).toBe(12)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user