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,
|
||||
form,
|
||||
InProgressRequest,
|
||||
InstanceIdResponse,
|
||||
InviteRequest,
|
||||
InviteResponse,
|
||||
JoinModeRequest,
|
||||
@@ -612,7 +613,6 @@ function nextLiveMessageId(): number {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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`
|
||||
@@ -662,6 +662,13 @@ async function sendGameInvite(
|
||||
*/
|
||||
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, …).
|
||||
* 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(
|
||||
'/player',
|
||||
describeRoute({
|
||||
|
||||
@@ -401,3 +401,15 @@ export const InviteResponse = z.object({
|
||||
.nullable()
|
||||
.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 () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player?id=1070&id=1380`)
|
||||
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||
@@ -2654,6 +2719,7 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /room/{roomId}/instances',
|
||||
'GET /rooms/requiring/developer',
|
||||
'GET /rooms/requiring/rrplus',
|
||||
'GET /tachyon',
|
||||
'POST /invite',
|
||||
'POST /matchmake/club/{clubId}',
|
||||
'POST /matchmake/dorm',
|
||||
|
||||
Reference in New Issue
Block a user