fix #19 clubhouses

This commit is contained in:
Devin Zuczek
2026-07-21 12:59:47 -04:00
parent d56fe3a276
commit 7f497fcee0
7 changed files with 691 additions and 21 deletions
+61
View File
@@ -8,6 +8,7 @@ import {
deleteExpiredPresence,
deletePresence,
getAccount,
getClubSummary,
getExpiredPresenceInstanceIds,
getJoinableInstance,
getOrCreateDormRoom,
@@ -16,6 +17,7 @@ import {
getRoomById,
getRoomByName,
getRoomInstancesByRoom,
isClubMember,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
@@ -718,6 +720,65 @@ const app = new Hono<App>()
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a club's clubhouse (`/matchmake/club/{clubId}`). Registered before
// the single-segment `/matchmake/:room` route so `club` isn't read as a room name.
// Members only: the clubhouse is the club's private space, so a non-member (or
// someone with a pending request, or banned) is refused rather than let in.
.post(
'/matchmake/club/:clubId{[0-9]+}',
describeRoute({
tags: ['Navigation'],
summary: 'Matchmake into a clubs clubhouse',
description:
'Looks the club up, checks the caller is a member of it, and places them into an ' +
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the ' +
'club is unknown, has no clubhouse set, or the caller isnt a member.',
security: AUTHED,
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
parameters: [
{
name: 'clubId',
in: 'path',
required: true,
description: 'Club id (digits only)',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: {
200: json(
MatchmakeResponse,
'The clubhouse instance (or errorCode 20 with null when it cant be entered)'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const club = await getClubSummary(c.env.DB, clubId)
// One response for "no such club", "no clubhouse", and "not a member": the
// client only needs "you're not going there", and a distinct code for the last
// case would tell a non-member which clubs exist and have a clubhouse.
if (!club?.clubhouseRoomId) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
if (!(await isClubMember(c.env.DB, clubId, id))) {
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
}
const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(
c,
String(club.clubhouseRoomId),
joinMode === 2,
id
)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
}
)
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
// — the client uses this to enter a room's other scenes). The subroom decides the
// scene the client loads and which instances are joinable, so it must be carried
@@ -108,6 +108,40 @@ beforeAll(async () => {
insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })),
insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })),
])
// Club tables (owned by the clubs worker) — matchmake/club reads the clubhouse
// room and the caller's membership from them.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club (
data TEXT NOT NULL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL
)`
).run()
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS club_member (
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
club_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
membership_type INTEGER NOT NULL DEFAULT 0,
created_at TEXT
)`
).run()
const insertClub = env.DB.prepare('INSERT OR IGNORE INTO club (data) VALUES (?1)')
await env.DB.batch([
// Club 4 has room 2 as its clubhouse; club 5 has none set.
insertClub.bind(JSON.stringify({ ClubId: 4, Name: 'Clubbers', ClubhouseRoomId: 2 })),
insertClub.bind(JSON.stringify({ ClubId: 5, Name: 'Homeless', ClubhouseRoomId: null })),
])
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type) VALUES (?1, ?2, ?3)'
)
await env.DB.batch([
insertMember.bind(4, 120, 100), // creator
insertMember.bind(4, 121, 10), // member
insertMember.bind(4, 122, 1), // pending request — not a member yet
insertMember.bind(4, 123, -1), // banned
insertMember.bind(5, 120, 100),
])
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -309,6 +343,58 @@ describe('public endpoints', () => {
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
})
test('POST /matchmake/club/:clubId places members into the clubhouse', async () => {
const matchmake = async (path: string, sub?: string) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'JoinMode=0',
})
type Body = {
errorCode: number
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
}
// A member lands in an instance of the club's clubhouse (room 2)...
const res = await matchmake('/matchmake/club/4', '121')
expect(res.status).toBe(200)
const body = (await res.json()) as Body
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
// ...and it's recorded as their presence, like any other matchmake.
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
.bind(121)
.first<{ data: string }>()
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
expect(presence.roomInstance.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// The creator is a member too, and joins the same public instance.
const creator = (await (await matchmake('/matchmake/club/4', '120')).json()) as Body
expect(creator.roomInstance?.roomInstanceId).toBe(body.roomInstance!.roomInstanceId)
// Everyone who isn't a member is turned away with the same answer: a non-member,
// a pending request, a banned account, a club with no clubhouse, an unknown club.
for (const [path, sub] of [
['/matchmake/club/4', '199'],
['/matchmake/club/4', '122'],
['/matchmake/club/4', '123'],
['/matchmake/club/5', '120'],
['/matchmake/club/9999', '120'],
] as const) {
expect(await (await matchmake(path, sub)).json()).toEqual({
errorCode: 20,
roomInstance: null,
})
}
// Signed out is a 401, not a matchmaking error.
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
})
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST',
@@ -896,6 +982,7 @@ describe('auth-gated endpoints', () => {
'GET /rooms/requiring/rrplus',
'POST /goto/none',
'POST /goto/room/{room}',
'POST /matchmake/club/{clubId}',
'POST /matchmake/none',
'POST /matchmake/room/{roomId}',
'POST /matchmake/room/{roomId}/{subRoomId}',