mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[match] tachyon support endpoint
This commit is contained in:
@@ -61,6 +61,7 @@ import {
|
|||||||
ExclusiveLoginResponse,
|
ExclusiveLoginResponse,
|
||||||
form,
|
form,
|
||||||
InProgressRequest,
|
InProgressRequest,
|
||||||
|
InstanceIdResponse,
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
InviteResponse,
|
InviteResponse,
|
||||||
JoinModeRequest,
|
JoinModeRequest,
|
||||||
@@ -612,7 +613,6 @@ function nextLiveMessageId(): number {
|
|||||||
return Date.now()
|
return Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived`
|
* Deliver a game invite from `fromId` to `toId` for a room instance — a `MessageReceived`
|
||||||
* frame carrying a game-invite `Message` the client renders the join prompt from. `data`
|
* frame carrying a game-invite `Message` the client renders the join prompt from. `data`
|
||||||
@@ -662,6 +662,13 @@ async function sendGameInvite(
|
|||||||
*/
|
*/
|
||||||
const ORIENTATION_INSTANCE_ID = -2
|
const ORIENTATION_INSTANCE_ID = -2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `/tachyon` answers for a player who is not in an instance. A stored
|
||||||
|
* `room_instance` id is never 0 — the table is AUTOINCREMENT from 1 — so the sentinel can't
|
||||||
|
* be mistaken for a real instance, and the body stays a number the way the caller reads it.
|
||||||
|
*/
|
||||||
|
const NO_INSTANCE = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -1389,6 +1396,56 @@ const app = new Hono<App>()
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Which room instance a player is in, as a bare number (`/tachyon?id=123`). The whole
|
||||||
|
// presence blob is what `/player` serves; this answers the one field out of it, for a
|
||||||
|
// caller that only needs to know where someone is.
|
||||||
|
//
|
||||||
|
// UNGATED, unlike the rest of this worker's presence surface: it takes the player id from
|
||||||
|
// the query rather than from a token, so anyone can ask about anyone. What it discloses
|
||||||
|
// is an instance id and nothing else — no room, no name, no status — and `/player` is
|
||||||
|
// already ungated on the same rows.
|
||||||
|
//
|
||||||
|
// 0 for "not in an instance": the body is a number, so absence has to be one too, and a
|
||||||
|
// real instance id is never 0 (the `room_instance` table is AUTOINCREMENT from 1). The
|
||||||
|
// synthetic negatives ARE real answers and pass through — -2 is the Orientation seed.
|
||||||
|
.get(
|
||||||
|
'/tachyon',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Presence'],
|
||||||
|
summary: 'The room instance a player is in',
|
||||||
|
description: [
|
||||||
|
'The `roomInstanceId` from a player’s live presence (`?id=123`), as a BARE NUMBER —',
|
||||||
|
'the whole body is the id, not an object around it. The single field out of what',
|
||||||
|
'`/player` serves whole.',
|
||||||
|
'',
|
||||||
|
'0 means not in an instance: no live presence, an expired row, or no usable `id`. A',
|
||||||
|
'real instance id is never 0, so the sentinel can’t collide with one. Synthetic ids',
|
||||||
|
'pass through as they stand — -2 is the Orientation presence the `auth` worker seeds',
|
||||||
|
'a brand-new player with.',
|
||||||
|
'',
|
||||||
|
'Ungated: the player is named by the query rather than by a token, so anyone may ask',
|
||||||
|
'about anyone. It discloses an instance id and nothing else.',
|
||||||
|
].join(' '),
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'The account to look up. Absent or unparseable answers 0',
|
||||||
|
schema: { type: 'integer' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: { 200: json(InstanceIdResponse, 'The instance id, or 0') },
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const playerId = Number.parseInt(c.req.query('id') ?? '', 10)
|
||||||
|
if (!Number.isInteger(playerId)) return c.json(NO_INSTANCE)
|
||||||
|
|
||||||
|
const presence = await getPresence<RoomInstance>(c.env.DB, playerId)
|
||||||
|
return c.json(presence?.roomInstance?.roomInstanceId ?? NO_INSTANCE)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
.get(
|
.get(
|
||||||
'/player',
|
'/player',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -401,3 +401,15 @@ export const InviteResponse = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.describe('The room the invite points at; null when the room instance didn’t resolve'),
|
.describe('The room the invite points at; null when the room instance didn’t resolve'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /tachyon?id=…` — the room instance a player is in, as a BARE NUMBER: the whole body
|
||||||
|
* is the id, with no object around it.
|
||||||
|
*
|
||||||
|
* 0 means "not in one" — no live presence for that account, an expired row, or no `id`
|
||||||
|
* given. Presence rows carry synthetic ids too, which are passed through as they stand:
|
||||||
|
* -2 is the Orientation seed the `auth` worker writes for a brand-new player.
|
||||||
|
*/
|
||||||
|
export const InstanceIdResponse = z
|
||||||
|
.int()
|
||||||
|
.describe('The player’s room instance id, or 0 when they are not in one')
|
||||||
|
|||||||
@@ -332,6 +332,71 @@ describe('public endpoints', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('GET /tachyon?id=N answers the bare instance id the player is in', async () => {
|
||||||
|
// Ungated — no bearer token anywhere in this test. The player is named by the query.
|
||||||
|
const tachyon = async (query: string) => {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/tachyon${query}`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody has presence for 4242, so they are in nothing. 0 rather than null: the body
|
||||||
|
// is a number, and a real instance id is never 0.
|
||||||
|
expect(await tachyon('?id=4242')).toBe(0)
|
||||||
|
|
||||||
|
// Put someone in a room the ordinary way, and the id is the one the matchmake handed
|
||||||
|
// them — the same field `/player` serves inside the whole presence blob.
|
||||||
|
const matchmake = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('4243')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||||
|
})
|
||||||
|
const { roomInstance } = (await matchmake.json()) as {
|
||||||
|
roomInstance: { roomInstanceId: number } | null
|
||||||
|
}
|
||||||
|
expect(roomInstance).not.toBeNull()
|
||||||
|
expect(await tachyon('?id=4243')).toBe(roomInstance!.roomInstanceId)
|
||||||
|
|
||||||
|
// A synthetic instance id is a real answer and passes through — -2 is the Orientation
|
||||||
|
// presence `auth` seeds a new player with.
|
||||||
|
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: 4244,
|
||||||
|
roomInstance: { roomInstanceId: -2 },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) + 900,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
expect(await tachyon('?id=4244')).toBe(-2)
|
||||||
|
|
||||||
|
// An expired row is not presence, so its player is in nothing.
|
||||||
|
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId: 4245,
|
||||||
|
roomInstance: { roomInstanceId: 987 },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 1,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) - 60,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
expect(await tachyon('?id=4245')).toBe(0)
|
||||||
|
|
||||||
|
// No id, and an unparseable one, answer the same 0 rather than erroring.
|
||||||
|
expect(await tachyon('')).toBe(0)
|
||||||
|
expect(await tachyon('?id=notanumber')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('GET /player?id=&id= returns one payload per id, in order', async () => {
|
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 res = await exports.default.fetch(`${ORIGIN}/player?id=1070&id=1380`)
|
||||||
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||||
@@ -2654,6 +2719,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
'GET /room/{roomId}/instances',
|
'GET /room/{roomId}/instances',
|
||||||
'GET /rooms/requiring/developer',
|
'GET /rooms/requiring/developer',
|
||||||
'GET /rooms/requiring/rrplus',
|
'GET /rooms/requiring/rrplus',
|
||||||
|
'GET /tachyon',
|
||||||
'POST /invite',
|
'POST /invite',
|
||||||
'POST /matchmake/club/{clubId}',
|
'POST /matchmake/club/{clubId}',
|
||||||
'POST /matchmake/dorm',
|
'POST /matchmake/dorm',
|
||||||
|
|||||||
Reference in New Issue
Block a user