mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
subroom matchmake
This commit is contained in:
+99
-43
@@ -18,8 +18,8 @@ import {
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
import type { Room, StoredPresence } from '@repo/domain'
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
|
import type { Room, StoredPresence } from '@repo/domain'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,21 +32,45 @@ import type { App } from './context'
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default `/player` payload, served whenever the `id` is missing/invalid or the
|
* The connection fields the client expects on a player payload but that only ever
|
||||||
* account isn't found. Inlined here (Workers have no filesystem).
|
* carry a value in a matchmaking response — the photon/voice credentials for the
|
||||||
|
* instance you were just placed into. Reading someone else's presence never hands
|
||||||
|
* out credentials, so they're always null here; the client needs the keys present.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_GET_PLAYER = [
|
const NULL_CONNECTION_INFO = {
|
||||||
{
|
photonAuthToken: null,
|
||||||
playerId: 1,
|
photonRealtimeAppId: null,
|
||||||
statusVisibility: 0,
|
photonVoiceAppId: null,
|
||||||
deviceClass: 0,
|
photonChatAppId: null,
|
||||||
vrMovementMode: 1,
|
photonRegion: null,
|
||||||
roomInstance: null,
|
photonRoomId: null,
|
||||||
isOnline: true,
|
voiceConnectionInfo: null,
|
||||||
appVersion: '20230302',
|
voiceServerId: null,
|
||||||
platform: 0,
|
experiments: null,
|
||||||
},
|
} as const
|
||||||
]
|
|
||||||
|
/**
|
||||||
|
* A player's presence as the client reads it (`/player`, `/player/heartbeat`).
|
||||||
|
* `isOnline` means "has a live presence row" — presence rows expire, so a player who
|
||||||
|
* stopped heartbeating drops offline — and is deliberately *not* derived from being
|
||||||
|
* in a room: you can be online in the lobby with `roomInstance` null. `errorCode` 0
|
||||||
|
* is "no error"; it only turns non-zero on a failed matchmake.
|
||||||
|
*/
|
||||||
|
function playerPayload(playerId: number, presence?: Presence | null) {
|
||||||
|
return {
|
||||||
|
appVersion: presence?.appVersion || GAME_VERSION,
|
||||||
|
deviceClass: presence?.deviceClass ?? 0,
|
||||||
|
errorCode: 0,
|
||||||
|
// `getPresence` yields null and the batch map yields undefined — neither is online.
|
||||||
|
isOnline: presence != null,
|
||||||
|
playerId,
|
||||||
|
roomInstance: presence?.roomInstance ?? null,
|
||||||
|
statusVisibility: presence?.statusVisibility ?? 0,
|
||||||
|
vrMovementMode: presence?.vrMovementMode ?? 1,
|
||||||
|
platform: presence?.platform ?? 0,
|
||||||
|
...NULL_CONNECTION_INFO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Heartbeat body posted by the client (all fields optional). */
|
/** Heartbeat body posted by the client (all fields optional). */
|
||||||
interface HeartbeatRequest {
|
interface HeartbeatRequest {
|
||||||
@@ -98,6 +122,13 @@ const PRESENCE_REFRESH_THRESHOLD = 300
|
|||||||
*/
|
*/
|
||||||
const GAME_VERSION = '20230302'
|
const GAME_VERSION = '20230302'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default `/player` payload, served whenever the `id` is missing/invalid or the
|
||||||
|
* account isn't found. Inlined here (Workers have no filesystem). The stub player
|
||||||
|
* reads as online — it's a placeholder for a real, present player.
|
||||||
|
*/
|
||||||
|
const DEFAULT_GET_PLAYER = [{ ...playerPayload(1), isOnline: true }]
|
||||||
|
|
||||||
/** Store the room instance the player just matchmade into, preserving status. */
|
/** Store the room instance the player just matchmade into, preserving status. */
|
||||||
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
|
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
|
||||||
const prev = await getPresence<RoomInstance>(c.env.DB, id)
|
const prev = await getPresence<RoomInstance>(c.env.DB, id)
|
||||||
@@ -165,10 +196,18 @@ function dormRoomInstance() {
|
|||||||
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
|
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
|
||||||
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
|
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
|
||||||
* makes the client reject the session with "unknown scene location ID".
|
* makes the client reject the session with "unknown scene location ID".
|
||||||
|
*
|
||||||
|
* `subRoomId` picks which of the room's subrooms to enter (the client matchmakes
|
||||||
|
* into one with `/matchmake/room/{roomId}/{subRoomId}`); an unknown or unspecified
|
||||||
|
* subroom falls back to the room's first, which is its default entrance.
|
||||||
*/
|
*/
|
||||||
function instanceFieldsFromRoom(room: Room) {
|
function instanceFieldsFromRoom(room: Room, subRoomId?: number) {
|
||||||
const sub = (Array.isArray(room.SubRooms) ? room.SubRooms[0] : undefined) as
|
const subRooms = (Array.isArray(room.SubRooms) ? room.SubRooms : []) as Array<
|
||||||
Record<string, unknown> | undefined
|
Record<string, unknown>
|
||||||
|
>
|
||||||
|
const sub =
|
||||||
|
(subRoomId === undefined ? undefined : subRooms.find((s) => s.SubRoomId === subRoomId)) ??
|
||||||
|
subRooms[0]
|
||||||
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
||||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
|
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
|
||||||
// Room instance names are prefixed with `^` so the client resolves the instance
|
// Room instance names are prefixed with `^` so the client resolves the instance
|
||||||
@@ -197,9 +236,10 @@ function roomInstanceFromRoom(
|
|||||||
room: Room,
|
room: Room,
|
||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
instanceId: number,
|
instanceId: number,
|
||||||
photonRoomId: string
|
photonRoomId: string,
|
||||||
|
subRoomId?: number
|
||||||
): RoomInstance {
|
): RoomInstance {
|
||||||
const f = instanceFieldsFromRoom(room)
|
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||||
return {
|
return {
|
||||||
roomInstanceId: instanceId,
|
roomInstanceId: instanceId,
|
||||||
roomId: f.roomId,
|
roomId: f.roomId,
|
||||||
@@ -237,7 +277,8 @@ async function resolveRoomInstance(
|
|||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
roomKey: string,
|
roomKey: string,
|
||||||
isPrivate: boolean,
|
isPrivate: boolean,
|
||||||
ownerId: number
|
ownerId: number,
|
||||||
|
subRoomId?: number
|
||||||
): Promise<RoomInstance | null> {
|
): Promise<RoomInstance | null> {
|
||||||
const id = Number.parseInt(roomKey, 10)
|
const id = Number.parseInt(roomKey, 10)
|
||||||
const room = Number.isNaN(id)
|
const room = Number.isNaN(id)
|
||||||
@@ -245,10 +286,11 @@ async function resolveRoomInstance(
|
|||||||
: await getRoomById(c.env.DB, id)
|
: await getRoomById(c.env.DB, id)
|
||||||
if (!room) return null
|
if (!room) return null
|
||||||
|
|
||||||
const f = instanceFieldsFromRoom(room)
|
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||||
// Reuse an existing joinable public instance; private matchmakes always get a
|
// Reuse an existing joinable public instance *of the same subroom* — subrooms are
|
||||||
// fresh instance. Create one when there's nothing to join.
|
// separate places, so joining one must never land you in another. Private
|
||||||
let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId)
|
// matchmakes always get a fresh instance. Create one when there's nothing to join.
|
||||||
|
let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId)
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
instance = await createRoomInstance(c.env.DB, {
|
instance = await createRoomInstance(c.env.DB, {
|
||||||
ownerAccountId: ownerId,
|
ownerAccountId: ownerId,
|
||||||
@@ -263,7 +305,13 @@ async function resolveRoomInstance(
|
|||||||
roomInstanceType: f.roomInstanceType,
|
roomInstanceType: f.roomInstanceType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId)
|
return roomInstanceFromRoom(
|
||||||
|
room,
|
||||||
|
isPrivate,
|
||||||
|
instance.roomInstanceId,
|
||||||
|
instance.photonRoomId,
|
||||||
|
f.subRoomId
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,20 +381,7 @@ const app = new Hono<App>()
|
|||||||
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
|
// One query for the whole batch (D1 `WHERE account_id IN (…)`), rather than a
|
||||||
// point read per id as the KV store required.
|
// point read per id as the KV store required.
|
||||||
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
|
const presences = await getPresences<RoomInstance>(c.env.DB, ids)
|
||||||
const players = ids.map((playerId) => {
|
return c.json(ids.map((playerId) => playerPayload(playerId, presences.get(playerId))))
|
||||||
const p = presences.get(playerId)
|
|
||||||
return {
|
|
||||||
playerId,
|
|
||||||
statusVisibility: p?.statusVisibility ?? 0,
|
|
||||||
deviceClass: p?.deviceClass ?? 0,
|
|
||||||
vrMovementMode: p?.vrMovementMode ?? 1,
|
|
||||||
roomInstance: p?.roomInstance ?? null,
|
|
||||||
isOnline: p?.roomInstance != null,
|
|
||||||
appVersion: p?.appVersion || GAME_VERSION,
|
|
||||||
platform: p?.platform ?? 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return c.json(players)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
.post('/player/heartbeat', async (c) => {
|
.post('/player/heartbeat', async (c) => {
|
||||||
@@ -398,13 +433,13 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The heartbeat echoes the same player payload `/player` serves; with no stored
|
||||||
|
// presence it falls back to what the client just posted.
|
||||||
return c.json({
|
return c.json({
|
||||||
playerId: hb.playerId ? hb.playerId : id,
|
...playerPayload(hb.playerId ? hb.playerId : id, presence),
|
||||||
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
||||||
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
||||||
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
||||||
roomInstance: presence?.roomInstance ?? null,
|
|
||||||
isOnline: presence?.roomInstance != null,
|
|
||||||
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
||||||
platform: presence?.platform ?? hb.platform ?? 0,
|
platform: presence?.platform ?? hb.platform ?? 0,
|
||||||
})
|
})
|
||||||
@@ -463,6 +498,27 @@ const app = new Hono<App>()
|
|||||||
if (id !== null) await enterRoom(c, id, instance)
|
if (id !== null) await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: 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
|
||||||
|
// through; an unknown subroom falls back to the room's first.
|
||||||
|
.post('/matchmake/room/:roomId/:subRoomId{[0-9]+}', async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
const joinMode = await readJoinMode(c)
|
||||||
|
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||||
|
const instance = await resolveRoomInstance(
|
||||||
|
c,
|
||||||
|
c.req.param('roomId'),
|
||||||
|
joinMode === 2,
|
||||||
|
id,
|
||||||
|
subRoomId
|
||||||
|
)
|
||||||
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
|
await enterRoom(c, id, instance)
|
||||||
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
|
})
|
||||||
|
|
||||||
// The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up
|
// The 2023 client uses a two-segment matchmake/room/{roomId}. Look the room up
|
||||||
// in D1 so the instance carries its real scene, and store it as presence.
|
// in D1 so the instance carries its real scene, and store it as presence.
|
||||||
.post('/matchmake/room/:roomId', async (c) => {
|
.post('/matchmake/room/:roomId', async (c) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const ORIGIN = 'https://example.com'
|
|||||||
// Matchmaking into a room resolves its real scene from the shared recflare D1.
|
// Matchmaking into a room resolves its real scene from the shared recflare D1.
|
||||||
// Seed the schema + a couple of rooms (matching the rooms worker's migration).
|
// Seed the schema + a couple of rooms (matching the rooms worker's migration).
|
||||||
const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6'
|
const RECCENTER_SCENE = 'cbad71af-0831-44d8-b8ef-69edafa841f6'
|
||||||
|
const SECOND_SUBROOM_SCENE = '3f0f6cd0-5c9f-42b2-9c07-2a5a2a1c9f11'
|
||||||
const TEST_ROOMS = [
|
const TEST_ROOMS = [
|
||||||
{
|
{
|
||||||
RoomId: 1,
|
RoomId: 1,
|
||||||
@@ -53,6 +54,18 @@ const TEST_ROOMS = [
|
|||||||
Accessibility: 1,
|
Accessibility: 1,
|
||||||
SubRooms: [{ SubRoomId: 5, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }],
|
SubRooms: [{ SubRoomId: 5, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 1 }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Two subrooms (separate scenes) — matchmaking into one must not land you in
|
||||||
|
// the other.
|
||||||
|
RoomId: 77,
|
||||||
|
Name: 'MultiRoom',
|
||||||
|
IsDorm: false,
|
||||||
|
Accessibility: 1,
|
||||||
|
SubRooms: [
|
||||||
|
{ SubRoomId: 34, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 10 },
|
||||||
|
{ SubRoomId: 35, UnitySceneId: SECOND_SUBROOM_SCENE, MaxPlayers: 6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -132,18 +145,38 @@ describe('public endpoints', () => {
|
|||||||
test('GET /player?id=N synthesizes a player payload for that id', async () => {
|
test('GET /player?id=N synthesizes a player payload for that id', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
|
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const players = (await res.json()) as Array<{
|
// The full presence shape the client deserializes — including the connection
|
||||||
playerId: number
|
// fields, which only ever carry values in a matchmaking response.
|
||||||
isOnline: boolean
|
expect(await res.json()).toEqual([
|
||||||
appVersion: string
|
{
|
||||||
roomInstance: unknown
|
appVersion: '20230302',
|
||||||
}>
|
deviceClass: 0,
|
||||||
expect(players[0]).toMatchObject({
|
errorCode: 0,
|
||||||
playerId: 99,
|
isOnline: false,
|
||||||
isOnline: false,
|
playerId: 99,
|
||||||
appVersion: '20230302',
|
roomInstance: null,
|
||||||
roomInstance: null,
|
statusVisibility: 0,
|
||||||
})
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
photonAuthToken: null,
|
||||||
|
photonRealtimeAppId: null,
|
||||||
|
photonVoiceAppId: null,
|
||||||
|
photonChatAppId: null,
|
||||||
|
photonRegion: null,
|
||||||
|
photonRoomId: null,
|
||||||
|
voiceConnectionInfo: null,
|
||||||
|
voiceServerId: null,
|
||||||
|
experiments: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('GET /player?id=&id= returns one payload per id, in order', async () => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/player?id=1070&id=1380`)
|
||||||
|
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||||
|
expect(players.map((p) => p.playerId)).toEqual([1070, 1380])
|
||||||
|
// Neither has presence → both offline.
|
||||||
|
expect(players.every((p) => p.isOnline === false)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /player without an id returns the default payload', async () => {
|
test('GET /player without an id returns the default payload', async () => {
|
||||||
@@ -190,6 +223,53 @@ describe('public endpoints', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /matchmake/room/:roomId/:subRoomId enters that subroom', async () => {
|
||||||
|
type Instance = {
|
||||||
|
roomId: number
|
||||||
|
subRoomId: number
|
||||||
|
location: string
|
||||||
|
maxCapacity: number
|
||||||
|
roomInstanceId: number
|
||||||
|
}
|
||||||
|
const matchmake = async (path: string, sub: string): Promise<Instance> => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...(await bearer(sub)),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
// The client's real body: JoinMode 0 (public) plus flags we ignore.
|
||||||
|
body: 'BypassMovementModeRestriction=True&MaxPersistenceVersion=41&JoinMode=0&ClientJoinData=%7B%22WelcomeMatName%22%3A%22%22%7D&AdditionalPlayersAutoFollow=False',
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { errorCode: number; roomInstance: Instance }
|
||||||
|
expect(body.errorCode).toBe(0)
|
||||||
|
return body.roomInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subroom 35 → that subroom's own scene and capacity, not the first subroom's.
|
||||||
|
const second = await matchmake('/matchmake/room/77/35', '90')
|
||||||
|
expect(second).toMatchObject({
|
||||||
|
roomId: 77,
|
||||||
|
subRoomId: 35,
|
||||||
|
location: SECOND_SUBROOM_SCENE,
|
||||||
|
maxCapacity: 6,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A second player asking for the same subroom joins the same instance...
|
||||||
|
const alsoSecond = await matchmake('/matchmake/room/77/35', '91')
|
||||||
|
expect(alsoSecond.roomInstanceId).toBe(second.roomInstanceId)
|
||||||
|
|
||||||
|
// ...but the other subroom is a separate place, with its own instance + scene.
|
||||||
|
const first = await matchmake('/matchmake/room/77/34', '92')
|
||||||
|
expect(first.roomInstanceId).not.toBe(second.roomInstanceId)
|
||||||
|
expect(first).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE, maxCapacity: 10 })
|
||||||
|
|
||||||
|
// An unknown subroom falls back to the room's first (its default entrance).
|
||||||
|
const unknown = await matchmake('/matchmake/room/77/999', '93')
|
||||||
|
expect(unknown).toMatchObject({ subRoomId: 34, location: RECCENTER_SCENE })
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
|
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -615,9 +695,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
|
|
||||||
test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => {
|
test('GET /room/:id/instances is auth-gated, owner-only, and lists the room’s instances', async () => {
|
||||||
// No token → 401.
|
// No token → 401.
|
||||||
expect(
|
expect((await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status).toBe(401)
|
||||||
(await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status
|
|
||||||
).toBe(401)
|
|
||||||
|
|
||||||
// Not the owner (room 3 is owned by account 42) → 403.
|
// Not the owner (room 3 is owned by account 42) → 403.
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
Reference in New Issue
Block a user