mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
moving some more things to domains
This commit is contained in:
@@ -263,3 +263,52 @@ export async function removeFriend(db: D1Database, a: number, b: number): Promis
|
||||
.bind(a, b)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
||||
|
||||
/**
|
||||
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
||||
* relationship with `otherId`. These flags are stored per player, so the write
|
||||
* targets the caller's OWN side of the row — `requester_*` when the caller
|
||||
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
||||
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
||||
* created with the caller as requester. Returns the relationship from `playerId`'s
|
||||
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
||||
* into the SQL is safe (same pattern as the room interaction toggles).
|
||||
*/
|
||||
export async function setRelationshipFlag(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<RelationshipResponse> {
|
||||
const existing = await findPair(db, playerId, otherId)
|
||||
const v = value ? 1 : 0
|
||||
if (!existing) {
|
||||
// New row: the caller is the requester, so the flag lives on the requester side.
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(playerId, otherId, RelationshipType.None, v)
|
||||
.run()
|
||||
} else {
|
||||
// Update whichever side the caller is on, leaving the other player's flag alone.
|
||||
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship SET ${side}_${flag} = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId, v)
|
||||
.run()
|
||||
}
|
||||
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toResponse(updated, playerId)
|
||||
: { PlayerID: otherId, RelationshipType: RelationshipType.None, Favorited: 0, Ignored: 0, Muted: 0 }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getRelationshipsForPlayer,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
} from '../relationships-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
|
||||
@@ -88,5 +89,25 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
return c.json(await addFriend(c.env.DB, id, target))
|
||||
})
|
||||
|
||||
// Ignore / mute another player (target arrives as `PlayerId` in the POST body).
|
||||
// These set a per-player flag on the *caller's* side of the relationship row,
|
||||
// creating a bare (None) row when the pair aren't otherwise related — so you can
|
||||
// ignore/mute someone you've never friended. Auth-gated. Returns the resulting
|
||||
// relationship from the caller's point of view.
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/ignore', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'ignored', true))
|
||||
})
|
||||
.on(['GET', 'POST'], '/api/relationships/v1/mute', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await targetPlayerId(c)
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
return c.json(await setRelationshipFlag(c.env.DB, id, target, 'muted', true))
|
||||
})
|
||||
|
||||
.get('/api/messages/v2/get', (c) => c.json([]))
|
||||
.get('/api/messages/v1/favoriteFriendOnlineStatus', (c) => c.json([]))
|
||||
|
||||
@@ -649,6 +649,8 @@ describe('relationships', () => {
|
||||
'/api/relationships/v2/acceptfriendrequest',
|
||||
'/api/relationships/v2/removefriend',
|
||||
'/api/relationships/v2/addfriend',
|
||||
'/api/relationships/v1/ignore',
|
||||
'/api/relationships/v1/mute',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}?id=1`)
|
||||
expect(res.status).toBe(401)
|
||||
@@ -693,4 +695,46 @@ describe('relationships', () => {
|
||||
test('a self-targeted request is rejected', async () => {
|
||||
expect((await mutate('/api/relationships/v2/sendfriendrequest', '530', 530)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('v1 ignore/mute set the caller’s own side of the relationship', async () => {
|
||||
type FullRel = { PlayerID: number; RelationshipType: number; Ignored: number; Muted: number }
|
||||
// POST the real client shape: form body `PlayerId=<id>`.
|
||||
const flag = async (path: string, sub: string, playerId: number) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `PlayerId=${playerId}`,
|
||||
})
|
||||
).json()) as FullRel
|
||||
|
||||
// 700 ignores 701 with no prior relationship → a bare None row, the caller's side flagged.
|
||||
expect(await flag('/api/relationships/v1/ignore', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
RelationshipType: 0,
|
||||
Ignored: 1,
|
||||
Muted: 0,
|
||||
})
|
||||
// 700 then mutes 701 → same row, mute added, the earlier ignore preserved.
|
||||
expect(await flag('/api/relationships/v1/mute', '700', 701)).toMatchObject({
|
||||
PlayerID: 701,
|
||||
Ignored: 1,
|
||||
Muted: 1,
|
||||
})
|
||||
|
||||
// The tricky case: the caller is the row's TARGET. 710 sends 711 a request
|
||||
// (710 = requester); 711 ignoring 710 must flag the target side, not the requester's.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '710', 711)
|
||||
expect(await flag('/api/relationships/v1/ignore', '711', 710)).toMatchObject({
|
||||
PlayerID: 710,
|
||||
RelationshipType: 2, // 711 sees 710's request as Received
|
||||
Ignored: 1,
|
||||
})
|
||||
// 710's own side is untouched — the requester never ignored anyone.
|
||||
const view710 = (await relationships('710')) as unknown as FullRel[]
|
||||
expect(view710).toEqual([expect.objectContaining({ PlayerID: 711, RelationshipType: 1, Ignored: 0 })])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,18 @@ import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
createRoomInstance,
|
||||
getJoinableInstance,
|
||||
getOrCreateDormRoom,
|
||||
getRoomById,
|
||||
getRoomByName,
|
||||
getRoomInstancesByRoom,
|
||||
RoomInstanceType,
|
||||
setRoomInstanceInProgress,
|
||||
} from '@repo/domain'
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
createRoomInstance,
|
||||
getJoinableInstance,
|
||||
getRoomInstancesByRoom,
|
||||
setRoomInstanceInProgress,
|
||||
} from './room-instance-db'
|
||||
|
||||
import type { Room } from '@repo/domain'
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
@@ -516,6 +513,22 @@ const app = new Hono<App>()
|
||||
return c.body(null, 200)
|
||||
})
|
||||
|
||||
// The room's live instances — the owner's view of active sessions of their room.
|
||||
// Auth-gated (401) and owner-only (403): the caller must be the room's creator.
|
||||
// Unknown room → 404. Returns the bare RoomInstance DTO array (empty when the
|
||||
// room has no live instances).
|
||||
.get('/room/:roomId{[0-9]+}/instances', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.body(null, 404)
|
||||
if (room.CreatorAccountId !== id) return c.body(null, 403)
|
||||
|
||||
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
|
||||
})
|
||||
|
||||
// Rooms flagged as needing a developer/moderator to spawn in. No such queue
|
||||
// yet → empty list.
|
||||
.get('/rooms/requiring/developer', (c) => c.json([]))
|
||||
|
||||
@@ -2,9 +2,9 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../match.app'
|
||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL } from '../../room-instance-db'
|
||||
import '../../match.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -32,6 +32,14 @@ const TEST_ROOMS = [
|
||||
Accessibility: 1,
|
||||
SubRooms: [{ SubRoomId: 2, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 12 }],
|
||||
},
|
||||
{
|
||||
RoomId: 3,
|
||||
Name: 'TestersRoom',
|
||||
IsDorm: false,
|
||||
Accessibility: 1,
|
||||
CreatorAccountId: 42,
|
||||
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
||||
},
|
||||
]
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -478,4 +486,42 @@ describe('auth-gated endpoints', () => {
|
||||
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||
expect(players[0]).toMatchObject({ playerId: 55, isOnline: true })
|
||||
})
|
||||
|
||||
test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => {
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status
|
||||
).toBe(401)
|
||||
|
||||
// Not the owner (room 3 is owned by account 42) → 403.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||
headers: await bearer('999'),
|
||||
})
|
||||
).status
|
||||
).toBe(403)
|
||||
|
||||
// Unknown room → 404.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/room/99999/instances`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
|
||||
// Matchmaking into room 3 creates an instance the owner can then see.
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
const res = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1)
|
||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* Room instances — live sessions of a room. Stored with the same JSON-blob pattern
|
||||
* as the rooms/accounts tables: the full instance is a JSON blob in `data`, and
|
||||
* every field is a SQLite generated (virtual) column extracted from it (snake_case
|
||||
* per the C# `[Column]` names). `id` is a sequential key held in the blob.
|
||||
*
|
||||
* The `rooms` worker owns the schema (migrations/0004_room_instance.sql). The match
|
||||
* worker finds/creates instances here — keep this in sync. Columns marked
|
||||
* `[JsonIgnore]` in the C# (owner_account_id, data_blob, allow_new_users,
|
||||
* join_disabled) live in the blob but are dropped from the client DTO (`toDto`).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_instance (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
|
||||
owner_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ownerAccountId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomId')) VIRTUAL,
|
||||
sub_room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.subRoomId')) VIRTUAL,
|
||||
location TEXT GENERATED ALWAYS AS (json_extract(data, '$.location')) VIRTUAL,
|
||||
data_blob TEXT GENERATED ALWAYS AS (json_extract(data, '$.dataBlob')) VIRTUAL,
|
||||
event_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.eventId')) VIRTUAL,
|
||||
photon_region_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRegionId')) VIRTUAL,
|
||||
photon_room_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRoomId')) VIRTUAL,
|
||||
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.name')) VIRTUAL,
|
||||
max_capacity INTEGER GENERATED ALWAYS AS (json_extract(data, '$.maxCapacity')) VIRTUAL,
|
||||
is_full INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isFull')) VIRTUAL,
|
||||
is_private INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isPrivate')) VIRTUAL,
|
||||
is_in_progress INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInProgress')) VIRTUAL,
|
||||
room_code TEXT GENERATED ALWAYS AS (json_extract(data, '$.roomCode')) VIRTUAL,
|
||||
room_instance_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceType')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.clubId')) VIRTUAL,
|
||||
encrypt_voice_chat INTEGER GENERATED ALWAYS AS (json_extract(data, '$.EncryptVoiceChat')) VIRTUAL,
|
||||
matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL,
|
||||
allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL,
|
||||
join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL,
|
||||
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_instance_room_id ON room_instance (room_id)`,
|
||||
]
|
||||
|
||||
/** Client-facing RoomInstance JSON (JsonPropertyName keys; JsonIgnore omitted). */
|
||||
export interface RoomInstanceDto {
|
||||
roomInstanceId: number
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
location: string
|
||||
eventId: number
|
||||
photonRegionId: string
|
||||
photonRoomId: string
|
||||
name: string
|
||||
maxCapacity: number
|
||||
isFull: boolean
|
||||
isPrivate: boolean
|
||||
isInProgress: boolean
|
||||
roomCode: string
|
||||
roomInstanceType: number
|
||||
clubId: number
|
||||
// PascalCase JSON key, per the C# `[JsonPropertyName("EncryptVoiceChat")]`.
|
||||
EncryptVoiceChat: boolean
|
||||
matchmakingPolicy: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
|
||||
interface StoredRoomInstance extends RoomInstanceDto {
|
||||
ownerAccountId: number
|
||||
dataBlob: string
|
||||
allowNewUsers: boolean
|
||||
joinDisabled: boolean
|
||||
}
|
||||
|
||||
/** Fields for a new instance; `roomInstanceId` and `createdAt` are assigned here. */
|
||||
export interface NewRoomInstance {
|
||||
ownerAccountId: number
|
||||
roomId: number
|
||||
photonRoomId: string
|
||||
subRoomId?: number
|
||||
location?: string
|
||||
dataBlob?: string
|
||||
eventId?: number
|
||||
photonRegionId?: string
|
||||
name?: string
|
||||
maxCapacity?: number
|
||||
isFull?: boolean
|
||||
isPrivate?: boolean
|
||||
isInProgress?: boolean
|
||||
roomCode?: string
|
||||
roomInstanceType?: number
|
||||
clubId?: number
|
||||
encryptVoiceChat?: boolean
|
||||
matchmakingPolicy?: number
|
||||
allowNewUsers?: boolean
|
||||
joinDisabled?: boolean
|
||||
}
|
||||
|
||||
/** Project a stored instance to the client DTO (JsonIgnore fields dropped). */
|
||||
function toDto(s: StoredRoomInstance): RoomInstanceDto {
|
||||
return {
|
||||
roomInstanceId: s.roomInstanceId,
|
||||
roomId: s.roomId,
|
||||
subRoomId: s.subRoomId,
|
||||
location: s.location,
|
||||
eventId: s.eventId,
|
||||
photonRegionId: s.photonRegionId,
|
||||
photonRoomId: s.photonRoomId,
|
||||
name: s.name,
|
||||
maxCapacity: s.maxCapacity,
|
||||
isFull: s.isFull,
|
||||
isPrivate: s.isPrivate,
|
||||
isInProgress: s.isInProgress,
|
||||
roomCode: s.roomCode,
|
||||
roomInstanceType: s.roomInstanceType,
|
||||
clubId: s.clubId,
|
||||
EncryptVoiceChat: s.EncryptVoiceChat,
|
||||
matchmakingPolicy: s.matchmakingPolicy,
|
||||
createdAt: s.createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
const parse = (data: string): StoredRoomInstance => JSON.parse(data) as StoredRoomInstance
|
||||
|
||||
/**
|
||||
* Ids start high (above 1_000_000) so an instance id never collides with the
|
||||
* dorm's fixed roomInstanceId of 1 — the client keys room transitions off the id,
|
||||
* so a room instance that returned 1 would look like "still in the dorm".
|
||||
*/
|
||||
const ID_BASE = 1_000_000
|
||||
|
||||
/** Insert a new room instance, returning it as a client DTO. */
|
||||
export async function createRoomInstance(
|
||||
db: D1Database,
|
||||
input: NewRoomInstance
|
||||
): Promise<RoomInstanceDto> {
|
||||
const idRow = await db
|
||||
.prepare(`SELECT COALESCE(MAX(id), ${ID_BASE}) + 1 AS next FROM room_instance`)
|
||||
.first<{ next: number }>()
|
||||
const stored: StoredRoomInstance = {
|
||||
roomInstanceId: idRow?.next ?? ID_BASE + 1,
|
||||
ownerAccountId: input.ownerAccountId,
|
||||
roomId: input.roomId,
|
||||
subRoomId: input.subRoomId ?? 0,
|
||||
location: input.location ?? '',
|
||||
dataBlob: input.dataBlob ?? '',
|
||||
eventId: input.eventId ?? 0,
|
||||
photonRegionId: input.photonRegionId ?? 'us',
|
||||
photonRoomId: input.photonRoomId,
|
||||
name: input.name ?? '',
|
||||
maxCapacity: input.maxCapacity ?? 0,
|
||||
isFull: input.isFull ?? false,
|
||||
isPrivate: input.isPrivate ?? false,
|
||||
isInProgress: input.isInProgress ?? false,
|
||||
roomCode: input.roomCode ?? '',
|
||||
roomInstanceType: input.roomInstanceType ?? 0,
|
||||
clubId: input.clubId ?? 0,
|
||||
EncryptVoiceChat: input.encryptVoiceChat ?? false,
|
||||
matchmakingPolicy: input.matchmakingPolicy ?? 0,
|
||||
allowNewUsers: input.allowNewUsers ?? true,
|
||||
joinDisabled: input.joinDisabled ?? false,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
await db.prepare('INSERT INTO room_instance (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
||||
return toDto(stored)
|
||||
}
|
||||
|
||||
/** Look up a room instance by its id (roomInstanceId). */
|
||||
export async function getRoomInstance(db: D1Database, id: number): Promise<RoomInstanceDto | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM room_instance WHERE id = ?1')
|
||||
.bind(id)
|
||||
.first<{ data: string }>()
|
||||
return row ? toDto(parse(row.data)) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The oldest joinable public instance of a room (not private, not full, joins
|
||||
* enabled), or null when there's none to join. Used by matchmaking to reuse an
|
||||
* existing instance before creating a new one.
|
||||
*/
|
||||
export async function getJoinableInstance(
|
||||
db: D1Database,
|
||||
roomId: number
|
||||
): Promise<RoomInstanceDto | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT data FROM room_instance
|
||||
WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0
|
||||
ORDER BY id LIMIT 1`
|
||||
)
|
||||
.bind(roomId)
|
||||
.first<{ data: string }>()
|
||||
return row ? toDto(parse(row.data)) : null
|
||||
}
|
||||
|
||||
/** All instances of a given room. */
|
||||
export async function getRoomInstancesByRoom(
|
||||
db: D1Database,
|
||||
roomId: number
|
||||
): Promise<RoomInstanceDto[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM room_instance WHERE room_id = ?1')
|
||||
.bind(roomId)
|
||||
.all<{ data: string }>()
|
||||
return results.map((r) => toDto(parse(r.data)))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
cloneRoom,
|
||||
cloneSubRoom,
|
||||
findSubRoom,
|
||||
getBaseRooms,
|
||||
getFavoritedRooms,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getVisitedRooms,
|
||||
modifySubRoom,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
saveSubRoomData,
|
||||
@@ -664,6 +666,98 @@ const app = new Hono<App>()
|
||||
return c.json(findSubRoom(updated, subRoomId) ?? {})
|
||||
})
|
||||
|
||||
// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
|
||||
// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
|
||||
// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
|
||||
// envelope at HTTP 200, matching the other owner-gated room mutations.
|
||||
.put('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This room does not exist!',
|
||||
})
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
Error: 'You are not the owner of this room!',
|
||||
})
|
||||
}
|
||||
if (!findSubRoom(room, subRoomId)) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.InvalidName',
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const accessibility =
|
||||
typeof body.accessibility === 'string' ? Number.parseInt(body.accessibility, 10) : Number.NaN
|
||||
const maxPlayers =
|
||||
typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN
|
||||
|
||||
const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
|
||||
name,
|
||||
accessibility: Number.isNaN(accessibility) ? undefined : accessibility,
|
||||
maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
|
||||
})
|
||||
if (!updated) {
|
||||
return roomResult(c, {
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.DoesntExist',
|
||||
Error: 'This subroom does not exist!',
|
||||
})
|
||||
}
|
||||
|
||||
await pushRoomUpdate(c, accountId, updated)
|
||||
return roomResult(c, { Success: true })
|
||||
})
|
||||
|
||||
// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
|
||||
// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
|
||||
// returns the `{ success, error, value }` envelope with the new subroom as `value`,
|
||||
// mirroring the room-level `/clone`. Response shape is a best guess (the real
|
||||
// client's expected body is unknown).
|
||||
.post('/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone', async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) {
|
||||
return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
|
||||
}
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||
}
|
||||
|
||||
const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
|
||||
if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')
|
||||
|
||||
await pushRoomUpdate(c, accountId, result.room)
|
||||
return roomEnvelope(c, result.subRoom)
|
||||
})
|
||||
|
||||
// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
|
||||
// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
|
||||
// unknown/untagged.
|
||||
|
||||
@@ -3,14 +3,14 @@ import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../rooms.app'
|
||||
|
||||
import { ROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import importRooms from '../../../static/ImportRooms.json'
|
||||
import {
|
||||
createRoomInstance,
|
||||
getRoomInstance,
|
||||
SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL,
|
||||
} from '../../room-instance-db'
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import importRooms from '../../../static/ImportRooms.json'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -933,4 +933,71 @@ describe('rooms endpoints', () => {
|
||||
).json()) as unknown[]
|
||||
expect(visited).toEqual([])
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/subrooms/:sid/modify is auth-gated, owner-only, and persists subroom settings', async () => {
|
||||
const fields = { name: 'My Cool Subroom', accessibility: '1', maxPlayers: '20' }
|
||||
// No token → 401 (auth gate).
|
||||
expect((await putForm('/rooms/2/subrooms/2/modify', fields)).status).toBe(401)
|
||||
// Not the owner (room 2 is owned by account 1) → NotOwner.
|
||||
expect(await bodyOf(await putForm('/rooms/2/subrooms/2/modify', fields, '999'))).toMatchObject({
|
||||
Success: false,
|
||||
ErrorId: 'Rooms.NotOwner',
|
||||
})
|
||||
// Unknown room → DoesntExist.
|
||||
expect(
|
||||
await bodyOf(await putForm('/rooms/99999/subrooms/2/modify', fields, '1'))
|
||||
).toMatchObject({ Success: false, ErrorId: 'Rooms.DoesntExist' })
|
||||
// Unknown subroom → DoesntExist.
|
||||
expect(await bodyOf(await putForm('/rooms/2/subrooms/9999/modify', fields, '1'))).toMatchObject(
|
||||
{ Success: false, ErrorId: 'Rooms.DoesntExist' }
|
||||
)
|
||||
// Empty name → InvalidName.
|
||||
expect(
|
||||
await bodyOf(await putForm('/rooms/2/subrooms/2/modify', { ...fields, name: ' ' }, '1'))
|
||||
).toMatchObject({ Success: false, ErrorId: 'Rooms.InvalidName' })
|
||||
|
||||
// Owner updates the subroom → Success, and it persists on the subroom descriptor.
|
||||
const ok = await putForm('/rooms/2/subrooms/2/modify', fields, '1')
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await bodyOf(ok)).toMatchObject({ Success: true })
|
||||
const sub = (await (await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/data`)).json()) as {
|
||||
Name: string
|
||||
Accessibility: number
|
||||
MaxPlayers: number
|
||||
}
|
||||
expect(sub).toMatchObject({ Name: 'My Cool Subroom', Accessibility: 1, MaxPlayers: 20 })
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/subrooms/:sid/clone is auth-gated, owner-only, and copies the subroom', async () => {
|
||||
const clone = async (roomId: number, subRoomId: number, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/${roomId}/subrooms/${subRoomId}/clone`, {
|
||||
method: 'POST',
|
||||
headers: sub ? await bearer(sub) : {},
|
||||
})
|
||||
const envelope = async (res: Response) =>
|
||||
(await res.json()) as {
|
||||
success: boolean
|
||||
error: string
|
||||
value: { SubRoomId: number; CreatorAccountId: number } | null
|
||||
}
|
||||
|
||||
// No token → 401.
|
||||
expect((await clone(2, 2)).status).toBe(401)
|
||||
// Not the owner → success:false envelope.
|
||||
expect((await envelope(await clone(2, 2, '999'))).success).toBe(false)
|
||||
// Unknown subroom → success:false envelope.
|
||||
expect((await envelope(await clone(2, 9999, '1'))).success).toBe(false)
|
||||
|
||||
// Owner clones → success, a fresh SubRoomId owned by the caller, fetchable on the room.
|
||||
const res = await clone(2, 2, '1')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await envelope(res)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.value?.SubRoomId).not.toBe(2)
|
||||
expect(body.value?.CreatorAccountId).toBe(1)
|
||||
const fetched = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/${body.value?.SubRoomId}/data`)
|
||||
).json()) as { SubRoomId: number }
|
||||
expect(fetched.SubRoomId).toBe(body.value?.SubRoomId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { RoomInstanceType, Accessibility, Role } from './enums'
|
||||
export * from './accounts-db'
|
||||
export * from './rooms-db'
|
||||
export * from './room-instance-db'
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
* every field is a SQLite generated (virtual) column extracted from it (snake_case
|
||||
* per the C# `[Column]` names). `id` is a sequential key held in the blob.
|
||||
*
|
||||
* Mirror of `apps/rooms/src/room-instance-db.ts` — the `rooms` worker owns the
|
||||
* schema (migrations/0004_room_instance.sql); this worker finds/creates instances
|
||||
* here at matchmake time, keeping this copy in sync. Columns marked
|
||||
* `[JsonIgnore]` in the C# (owner_account_id, data_blob, allow_new_users,
|
||||
* join_disabled) live in the blob but are dropped from the client DTO (`toDto`).
|
||||
* The `rooms` worker owns the schema (migrations/0004_room_instance.sql); the
|
||||
* `match` worker finds/creates instances here at matchmake time. This module is the
|
||||
* single source of truth for the helpers — both workers import it from
|
||||
* `@repo/domain`. Columns marked `[JsonIgnore]` in the C# (owner_account_id,
|
||||
* data_blob, allow_new_users, join_disabled) live in the blob but are dropped from
|
||||
* the client DTO (`toDto`).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
export const ROOM_INSTANCE_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_instance (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
|
||||
@@ -233,6 +233,83 @@ export async function saveSubRoomData(
|
||||
return room
|
||||
}
|
||||
|
||||
/** Fields from the client's subroom `modify` form (each applied only when supplied). */
|
||||
export interface ModifySubRoomInput {
|
||||
name?: string
|
||||
accessibility?: number
|
||||
maxPlayers?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers
|
||||
* (the fields the client's subroom `modify` form carries). Only the supplied
|
||||
* fields are changed; the whole room JSON is rewritten (subrooms live in it).
|
||||
* Returns the updated room, or null when the room or subroom doesn't exist.
|
||||
*/
|
||||
export async function modifySubRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
input: ModifySubRoomInput
|
||||
): Promise<Room | null> {
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return null
|
||||
const sub = findSubRoom(room, subRoomId)
|
||||
if (!sub) return null
|
||||
|
||||
if (input.name !== undefined) sub.Name = input.name
|
||||
if (input.accessibility !== undefined) sub.Accessibility = input.accessibility
|
||||
if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers
|
||||
|
||||
await db
|
||||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, JSON.stringify(room))
|
||||
.run()
|
||||
return room
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone an existing subroom into a new subroom of the same room, owned by
|
||||
* `accountId`. The copy keeps the source's scene/settings (and its saved data
|
||||
* blobs, so it loads identical content) but gets a fresh SubRoomId — the next
|
||||
* integer above the room's current subrooms. Returns the updated room and the new
|
||||
* subroom, or null when the room or source subroom doesn't exist.
|
||||
*/
|
||||
export async function cloneSubRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
accountId: number
|
||||
): Promise<{ room: Room; subRoom: Record<string, unknown> } | null> {
|
||||
const room = await getRoomById(db, roomId)
|
||||
if (!room) return null
|
||||
const source = findSubRoom(room, subRoomId)
|
||||
if (!source) return null
|
||||
|
||||
const subRooms = Array.isArray(room.SubRooms)
|
||||
? (room.SubRooms as Array<Record<string, unknown>>)
|
||||
: []
|
||||
const nextSubRoomId =
|
||||
subRooms.reduce((max, s) => {
|
||||
const id = typeof s.SubRoomId === 'number' ? s.SubRoomId : 0
|
||||
return id > max ? id : max
|
||||
}, 0) + 1
|
||||
|
||||
const subRoom: Record<string, unknown> = {
|
||||
...source,
|
||||
SubRoomId: nextSubRoomId,
|
||||
RoomId: room.RoomId,
|
||||
CreatorAccountId: accountId,
|
||||
}
|
||||
|
||||
room.SubRooms = [...subRooms, subRoom]
|
||||
await db
|
||||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||||
.bind(roomId, JSON.stringify(room))
|
||||
.run()
|
||||
return { room, subRoom }
|
||||
}
|
||||
|
||||
interface RoomRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user