mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
true matchmaking
This commit is contained in:
+68
-29
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { validateAndGetAccountId } from './jwt'
|
||||
import { createRoomInstance, getJoinableInstance } from './room-instance-db'
|
||||
import { getRoomById, getRoomByName } from './rooms-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -11,9 +12,9 @@ import type { App } from './context'
|
||||
import type { Room } from './rooms-db'
|
||||
|
||||
/**
|
||||
* The matchmaking surface. Database-backed endpoints are stubbed here — there's
|
||||
* no DB binding yet, so room/player lookups fall back to default values when
|
||||
* nothing is found.
|
||||
* The matchmaking surface. Rooms and room instances are D1-backed (matchmaking
|
||||
* finds/creates a `room_instance` row per session); player lookups still fall back
|
||||
* to default values when nothing is found. Presence lives in the match KV.
|
||||
*
|
||||
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -162,44 +163,60 @@ function dormRoomInstance() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a room instance from a stored D1 room — crucially using the room's real
|
||||
* SubRoom `UnitySceneId` as the instance `location` (an empty/unknown location
|
||||
* makes the client reject the session with "unknown scene location ID").
|
||||
* Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
|
||||
* The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
|
||||
* makes the client reject the session with "unknown scene location ID".
|
||||
*/
|
||||
function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance {
|
||||
const subRooms = room.SubRooms
|
||||
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
|
||||
function instanceFieldsFromRoom(room: Room) {
|
||||
const sub = (Array.isArray(room.SubRooms) ? room.SubRooms[0] : undefined) as
|
||||
Record<string, unknown> | undefined
|
||||
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
|
||||
const roomId = num(room.RoomId, 1)
|
||||
// All room instance names are prefixed with `^` (the username prefix `@` is a
|
||||
// separate thing, e.g. a dorm is `^@user's Dorm`). The client uses this prefix
|
||||
// to resolve the instance; without it the new scene won't load. Matches Stella.
|
||||
const rawName = str(room.Name, 'Room')
|
||||
const name = rawName.startsWith('^') ? rawName : `^${rawName}`
|
||||
// roomInstanceId must differ from the room the player is leaving — the client
|
||||
// keys the transition off it. The dorm is instance 1, so a room that also
|
||||
// returned 1 looked like "no change". Use the room id (per Stella), with a
|
||||
// unique suffix-free deterministic Photon room so public players share it.
|
||||
const photonRoomId = isPrivate ? `rec.${roomId}.${crypto.randomUUID()}` : `rec.${roomId}`
|
||||
return {
|
||||
roomInstanceId: roomId,
|
||||
roomId,
|
||||
roomId: num(room.RoomId, 1),
|
||||
subRoomId: num(sub?.SubRoomId, 1),
|
||||
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
||||
location: str(sub?.UnitySceneId),
|
||||
dataBlob: str(sub?.DataBlob),
|
||||
name: rawName.startsWith('^') ? rawName : `^${rawName}`,
|
||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
||||
isDorm: room.IsDorm === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the client instance wire shape from a stored room plus the live instance's
|
||||
* id + Photon room id (both come from the `room_instance` table so joiners of the
|
||||
* same instance share them).
|
||||
*/
|
||||
function roomInstanceFromRoom(
|
||||
room: Room,
|
||||
isPrivate: boolean,
|
||||
instanceId: number,
|
||||
photonRoomId: string
|
||||
): RoomInstance {
|
||||
const f = instanceFieldsFromRoom(room)
|
||||
return {
|
||||
roomInstanceId: instanceId,
|
||||
roomId: f.roomId,
|
||||
subRoomId: f.subRoomId,
|
||||
roomInstanceType: f.roomInstanceType,
|
||||
location: f.location,
|
||||
dataBlob: f.dataBlob,
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegion: 'us',
|
||||
photonRegionId: 'us',
|
||||
photonRoomId,
|
||||
name,
|
||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||
name: f.name,
|
||||
maxCapacity: f.maxCapacity,
|
||||
isFull: false,
|
||||
isPrivate: isPrivate || room.IsDorm === true,
|
||||
isPrivate: isPrivate || f.isDorm,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
}
|
||||
@@ -212,19 +229,41 @@ async function readJoinMode(c: Context<App>): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a room by `:room` path segment (numeric id or name) from D1 and build
|
||||
* its instance. Returns null when the room isn't found.
|
||||
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
||||
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||
* table) or create a new one. Returns null when the room isn't found.
|
||||
*/
|
||||
async function resolveRoomInstance(
|
||||
c: Context<App>,
|
||||
roomKey: string,
|
||||
isPrivate: boolean
|
||||
isPrivate: boolean,
|
||||
ownerId: number
|
||||
): Promise<RoomInstance | null> {
|
||||
const id = Number.parseInt(roomKey, 10)
|
||||
const room = Number.isNaN(id)
|
||||
? await getRoomByName(c.env.DB, roomKey)
|
||||
: await getRoomById(c.env.DB, id)
|
||||
return room ? roomInstanceFromRoom(room, isPrivate) : null
|
||||
if (!room) return null
|
||||
|
||||
const f = instanceFieldsFromRoom(room)
|
||||
// Reuse an existing joinable public instance; private 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)
|
||||
if (!instance) {
|
||||
instance = await createRoomInstance(c.env.DB, {
|
||||
ownerAccountId: ownerId,
|
||||
roomId: f.roomId,
|
||||
subRoomId: f.subRoomId,
|
||||
location: f.location,
|
||||
dataBlob: f.dataBlob,
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: f.name,
|
||||
maxCapacity: f.maxCapacity,
|
||||
isPrivate: isPrivate || f.isDorm,
|
||||
roomInstanceType: f.roomInstanceType,
|
||||
})
|
||||
}
|
||||
return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId)
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
@@ -350,7 +389,7 @@ const app = new Hono<App>()
|
||||
const instance =
|
||||
room.toLowerCase() === 'dormroom'
|
||||
? dormRoomInstance()
|
||||
: await resolveRoomInstance(c, room, joinMode === 2)
|
||||
: await resolveRoomInstance(c, room, 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 })
|
||||
@@ -381,7 +420,7 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const joinMode = await readJoinMode(c)
|
||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2)
|
||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), 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 })
|
||||
@@ -396,7 +435,7 @@ const app = new Hono<App>()
|
||||
const instance =
|
||||
room.toLowerCase() === 'dorm'
|
||||
? dormRoomInstance()
|
||||
: await resolveRoomInstance(c, room, joinMode === 2)
|
||||
: await resolveRoomInstance(c, room, 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 })
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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`).
|
||||
*/
|
||||
|
||||
/** 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)))
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../match.app'
|
||||
|
||||
import { SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL } from '../../room-instance-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -42,6 +44,8 @@ beforeAll(async () => {
|
||||
).run()
|
||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
|
||||
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
|
||||
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, using the same dev secret, so the
|
||||
@@ -255,15 +259,40 @@ describe('auth-gated endpoints', () => {
|
||||
}
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
// Must differ from the dorm's instance id (1) so the client treats this
|
||||
// as a new room and actually loads the scene.
|
||||
roomInstanceId: 2,
|
||||
name: '^RecCenter',
|
||||
location: RECCENTER_SCENE,
|
||||
isPrivate: true,
|
||||
})
|
||||
// Private instances get a unique Photon room id; public share `rec.<roomId>`.
|
||||
expect(body.roomInstance.photonRoomId.startsWith('rec.2')).toBe(true)
|
||||
// The instance id is the room_instance table id (high-based, so it never
|
||||
// collides with the dorm's fixed instance id of 1).
|
||||
expect(body.roomInstance.roomInstanceId).toBeGreaterThan(1)
|
||||
// Every non-dorm instance gets a fresh random Photon room id (a bare UUID).
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room reuses a public instance; a private one is fresh', async () => {
|
||||
const matchmake = async (joinMode?: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('900')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: joinMode ? new URLSearchParams({ JoinMode: joinMode }).toString() : undefined,
|
||||
})
|
||||
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
|
||||
|
||||
// Two public matchmakes into the same room share the (reused) instance.
|
||||
const a = await matchmake()
|
||||
const b = await matchmake()
|
||||
expect(a.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
expect(b.roomInstance.photonRoomId).toBe(a.roomInstance.photonRoomId)
|
||||
expect(b.roomInstance.roomInstanceId).toBe(a.roomInstance.roomInstanceId)
|
||||
|
||||
// A private matchmake (JoinMode 2) gets its own distinct instance.
|
||||
const priv = await matchmake('2')
|
||||
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room 401s without a token', async () => {
|
||||
|
||||
Reference in New Issue
Block a user