mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[match] better invites
This commit is contained in:
+10
-5
@@ -123,11 +123,16 @@ substitutes the same as asking by id.
|
|||||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||||
| `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` |
|
| `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` |
|
||||||
|
|
||||||
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
|
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker. This
|
||||||
this worker has no migrations of its own. The settings KV is owned by the
|
worker owns one table of its own, `room_invite` (`migrations/0001_room_invite.sql`, applied
|
||||||
`playersettings` worker; this worker touches exactly one key in it, the "avoid juniors"
|
under its own `d1_migrations_match` table so it doesn't clash with the other workers
|
||||||
preference, and its write merges (as that worker's own PUT does) so the rest of the
|
sharing the database): a row per game invite `POST /invite` sends, which is what gives the
|
||||||
player's settings survive.
|
invite the `RoomInviteId` the response carries. Its `created_at` is epoch seconds, so old
|
||||||
|
invites can be swept later.
|
||||||
|
|
||||||
|
The settings KV is owned by the `playersettings` worker; this worker touches exactly one
|
||||||
|
key in it, the "avoid juniors" preference, and its write merges (as that worker's own PUT
|
||||||
|
does) so the rest of the player's settings survive.
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Room invites — one row per game invite a player sends another ("come join me in this
|
||||||
|
-- room"), as `POST /invite` creates them. Generated from
|
||||||
|
-- packages/domain/src/room-invites-db.ts (ROOM_INVITE_SCHEMA_DDL) — keep in sync.
|
||||||
|
--
|
||||||
|
-- The invite that reaches the invitee is a live notification, not a row: the worker pushes
|
||||||
|
-- a MessageReceived frame the moment the invite is created. This table exists so the invite
|
||||||
|
-- has an id of its own — `RoomInviteId`, which the create response hands back — and so an
|
||||||
|
-- invite can be looked up or expired after the fact rather than vanishing with the frame.
|
||||||
|
|
||||||
|
-- `room_invite_id` is AUTOINCREMENT rather than a bare rowid alias: the id is handed to the
|
||||||
|
-- client, and expiring old invites deletes rows, so a reused id would point a client's stale
|
||||||
|
-- invite at somebody else's.
|
||||||
|
--
|
||||||
|
-- `room_id` is nullable because the invite is: the caller names a room INSTANCE, and one
|
||||||
|
-- that has already died (or was never real) leaves the invite with nothing to resolve — the
|
||||||
|
-- worker sends it anyway, with a null RoomId, so the row records the same thing.
|
||||||
|
--
|
||||||
|
-- `created_at` is epoch SECONDS, like `presence.expires_at` on the same database and for the
|
||||||
|
-- same reason: the sweep that will expire these compares it against `Date.now()/1000` in
|
||||||
|
-- SQL, and an integer compare needs no parsing.
|
||||||
|
CREATE TABLE IF NOT EXISTS room_invite (
|
||||||
|
room_invite_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_player_id INTEGER NOT NULL,
|
||||||
|
to_player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- For the expiry sweep: "everything older than X".
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_room_invite_created ON room_invite (created_at);
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"deploy": "run-wrangler-deploy",
|
"deploy": "run-wrangler-deploy",
|
||||||
"dev": "run-wrangler-dev",
|
"dev": "run-wrangler-dev",
|
||||||
"fix:workers-types": "run-wrangler-types",
|
"fix:workers-types": "run-wrangler-types",
|
||||||
|
"migrate": "run-wrangler-migrate",
|
||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
areFriends,
|
areFriends,
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
createRoomInstance,
|
createRoomInstance,
|
||||||
|
createRoomInvite,
|
||||||
deleteEmptyRoomInstances,
|
deleteEmptyRoomInstances,
|
||||||
deleteExpiredPresence,
|
deleteExpiredPresence,
|
||||||
deletePresence,
|
deletePresence,
|
||||||
@@ -61,6 +62,7 @@ import {
|
|||||||
form,
|
form,
|
||||||
InProgressRequest,
|
InProgressRequest,
|
||||||
InviteRequest,
|
InviteRequest,
|
||||||
|
InviteResponse,
|
||||||
JoinModeRequest,
|
JoinModeRequest,
|
||||||
json,
|
json,
|
||||||
jsonBody,
|
jsonBody,
|
||||||
@@ -610,6 +612,7 @@ 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`
|
||||||
@@ -2262,9 +2265,11 @@ const app = new Hono<App>()
|
|||||||
// `roomInstanceId` they're being invited into. Delivers a game-invite Message to the
|
// `roomInstanceId` they're being invited into. Delivers a game-invite Message to the
|
||||||
// target over the notify hub as a MessageReceived frame — the client renders the
|
// target over the notify hub as a MessageReceived frame — the client renders the
|
||||||
// join prompt from it. When the room instance resolves, its RoomId rides along on the
|
// join prompt from it. When the room instance resolves, its RoomId rides along on the
|
||||||
// message so the client knows which room the invite points at. Always acks 200 (a bad
|
// message so the client knows which room the invite points at. The invite is also
|
||||||
// playerId is a 400, a missing token a 401); hub delivery is best-effort, so a target
|
// recorded as a `room_invite` row, and that row IS the response —
|
||||||
// who's offline simply has the frame queued (or dropped) without failing the invite.
|
// `{ RoomInviteId, FromPlayerId, ToPlayerId, RoomId }` (a bad playerId is a 400, a
|
||||||
|
// missing token a 401). Hub delivery is best-effort, so a target who's offline simply
|
||||||
|
// has the frame queued (or dropped) without failing the invite.
|
||||||
.post(
|
.post(
|
||||||
'/invite',
|
'/invite',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -2274,14 +2279,16 @@ const app = new Hono<App>()
|
|||||||
'Sends a game invite from the caller (the Bearer token) to `playerId` for',
|
'Sends a game invite from the caller (the Bearer token) to `playerId` for',
|
||||||
'`roomInstanceId`. Delivered to the target over the notify hub as a `MessageReceived`',
|
'`roomInstanceId`. Delivered to the target over the notify hub as a `MessageReceived`',
|
||||||
'notification carrying a game-invite `Message`; the resolved instance’s `RoomId` rides',
|
'notification carrying a game-invite `Message`; the resolved instance’s `RoomId` rides',
|
||||||
'on the message. Acks 200 (bad `playerId` → 400); hub delivery is best-effort.',
|
'on the message. The invite is recorded as a `room_invite` row and that row is the',
|
||||||
|
'response (bad `playerId` → 400); hub delivery is best-effort.',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(InviteRequest, 'The target player and the room instance'),
|
requestBody: form(InviteRequest, 'The target player and the room instance'),
|
||||||
responses: {
|
responses: {
|
||||||
200: EMPTY_OK,
|
200: json(InviteResponse, 'The invite that was sent'),
|
||||||
400: { description: 'Missing, non-numeric, or zero playerId (empty body)' },
|
400: { description: 'Missing, non-numeric, or zero playerId (empty body)' },
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
500: { description: 'The invite could not be recorded; nothing was sent (empty body)' },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -2306,8 +2313,19 @@ const app = new Hono<App>()
|
|||||||
if (instance) roomId = instance.roomId
|
if (instance) roomId = instance.roomId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record the invite before delivering it: the row is what mints the `RoomInviteId`
|
||||||
|
// the response carries, while the frame itself is fire-and-forget.
|
||||||
|
const invite = await createRoomInvite(c.env.DB, id, toPlayerId, roomId)
|
||||||
|
// No row, no id to answer with — and nothing for a later lookup or expiry to find.
|
||||||
|
// A server fault rather than the caller's, so don't push the frame either: an
|
||||||
|
// invite the response can't name is worse than no invite.
|
||||||
|
if (invite === null) {
|
||||||
|
logger.error('failed to record room invite', { fromPlayerId: id, toPlayerId, roomId })
|
||||||
|
return c.body(null, 500)
|
||||||
|
}
|
||||||
|
|
||||||
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
|
await sendGameInvite(c, id, toPlayerId, roomInstanceIdStr, roomId)
|
||||||
return c.body(null, 200)
|
return c.json(invite)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -384,3 +384,17 @@ export const InviteRequest = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('The caller’s room instance to invite them into; resolves the invite’s RoomId'),
|
.describe('The caller’s room instance to invite them into; resolves the invite’s RoomId'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /invite` response — the `room_invite` row the invite just created. The frame the
|
||||||
|
* invitee receives is ephemeral; the row is what gives the invite an id of its own.
|
||||||
|
*/
|
||||||
|
export const InviteResponse = z.object({
|
||||||
|
RoomInviteId: z.int().describe('Id of the new `room_invite` row'),
|
||||||
|
FromPlayerId: z.int().describe('The caller (the Bearer token)'),
|
||||||
|
ToPlayerId: z.int().describe('The invited account'),
|
||||||
|
RoomId: z
|
||||||
|
.int()
|
||||||
|
.nullable()
|
||||||
|
.describe('The room the invite points at; null when the room instance didn’t resolve'),
|
||||||
|
})
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
|
ROOM_INVITE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
seedRoomWithSubRooms,
|
seedRoomWithSubRooms,
|
||||||
SUBROOM_SCHEMA_DDL,
|
SUBROOM_SCHEMA_DDL,
|
||||||
@@ -120,6 +121,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
|
// Presence table (owned by the rooms worker) — written/read by matchmake + heartbeat.
|
||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
// Room invites (owned by this worker) — POST /invite mints a row per invite.
|
||||||
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
|
||||||
// Accounts table (owned by the auth worker) — dorm creation reads the username
|
// Accounts table (owned by the auth worker) — dorm creation reads the username
|
||||||
// to name the room. Seed the players the dorm tests authenticate as.
|
// to name the room. Seed the players the dorm tests authenticate as.
|
||||||
@@ -2242,6 +2245,25 @@ describe('auth-gated endpoints', () => {
|
|||||||
// The client's exact request: player 42 invites 153 into their instance.
|
// The client's exact request: player 42 invites 153 into their instance.
|
||||||
const res = await invite(`playerId=153&roomInstanceId=${instance.roomInstanceId}`, '42')
|
const res = await invite(`playerId=153&roomInstanceId=${instance.roomInstanceId}`, '42')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
// The response is the `room_invite` row the invite just created.
|
||||||
|
const created = (await res.json()) as Record<string, unknown>
|
||||||
|
expect(created).toMatchObject({ FromPlayerId: 42, ToPlayerId: 153, RoomId: 2 })
|
||||||
|
expect(created.RoomInviteId).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// ...and the row is really there, `created_at` stamped in epoch seconds so the
|
||||||
|
// eventual expiry sweep can compare it.
|
||||||
|
const row = await env.DB.prepare(
|
||||||
|
'SELECT from_player_id, to_player_id, room_id, created_at FROM room_invite WHERE room_invite_id = ?1'
|
||||||
|
)
|
||||||
|
.bind(created.RoomInviteId)
|
||||||
|
.first<{
|
||||||
|
from_player_id: number
|
||||||
|
to_player_id: number
|
||||||
|
room_id: number | null
|
||||||
|
created_at: number
|
||||||
|
}>()
|
||||||
|
expect(row).toMatchObject({ from_player_id: 42, to_player_id: 153, room_id: 2 })
|
||||||
|
expect(row!.created_at).toBeGreaterThan(1_700_000_000)
|
||||||
|
|
||||||
const notes = await sent()
|
const notes = await sent()
|
||||||
expect(notes).toHaveLength(1)
|
expect(notes).toHaveLength(1)
|
||||||
@@ -2269,6 +2291,10 @@ describe('auth-gated endpoints', () => {
|
|||||||
// RoomId (which the real hub drops from the frame).
|
// RoomId (which the real hub drops from the frame).
|
||||||
const noRoom = await invite('playerId=153&roomInstanceId=999999', '42')
|
const noRoom = await invite('playerId=153&roomInstanceId=999999', '42')
|
||||||
expect(noRoom.status).toBe(200)
|
expect(noRoom.status).toBe(200)
|
||||||
|
const noRoomInvite = (await noRoom.json()) as Record<string, unknown>
|
||||||
|
expect(noRoomInvite).toMatchObject({ RoomId: null })
|
||||||
|
// Each invite gets its own id.
|
||||||
|
expect(noRoomInvite.RoomInviteId).not.toBe(created.RoomInviteId)
|
||||||
const after = await sent()
|
const after = await sent()
|
||||||
expect(after).toHaveLength(1)
|
expect(after).toHaveLength(1)
|
||||||
expect(after[0].data.RoomId).toBeNull()
|
expect(after[0].data.RoomId).toBeNull()
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
// Shared `recflare` DB. Reads room scenes for matchmaking, writes a player's
|
// Shared `recflare` DB. Reads room scenes for matchmaking, writes a player's
|
||||||
// personal dorm room on first dorm entry (see getOrCreateDormRoom), and holds
|
// personal dorm room on first dorm entry (see getOrCreateDormRoom), and holds
|
||||||
// player presence (the `presence` table — owned/migrated by the `rooms` worker).
|
// player presence (the `presence` table — owned/migrated by the `rooms` worker).
|
||||||
|
// This worker owns the `room_invite` table behind POST /invite (schema/migration
|
||||||
|
// here); its own migrations_table keeps that history separate from the other
|
||||||
|
// workers' migrations on the shared database.
|
||||||
// The "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time.
|
// The "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time.
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
"database_name": "recflare",
|
"database_name": "recflare",
|
||||||
"database_id": "local"
|
"database_id": "local",
|
||||||
|
"migrations_dir": "migrations",
|
||||||
|
"migrations_table": "d1_migrations_match"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Per-player settings KV, owned by the `playersettings` worker. Read-only here, for
|
// Per-player settings KV, owned by the `playersettings` worker. Read-only here, for
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './password'
|
|||||||
export * from './rooms-db'
|
export * from './rooms-db'
|
||||||
export * from './room-instance-db'
|
export * from './room-instance-db'
|
||||||
export * from './room-comments-db'
|
export * from './room-comments-db'
|
||||||
|
export * from './room-invites-db'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
export * from './inventory-invention-db'
|
export * from './inventory-invention-db'
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Room invites on the shared `recflare` D1 database — one row per game invite a player
|
||||||
|
* sends another ("come join me in this room"), as `POST /invite` on the `match` worker
|
||||||
|
* creates them.
|
||||||
|
*
|
||||||
|
* The invite that reaches the invitee is a live notification, not a row: `match` pushes a
|
||||||
|
* `MessageReceived` frame the moment the invite is created, and the client renders the join
|
||||||
|
* prompt straight off it. This table exists so the invite has an ID OF ITS OWN —
|
||||||
|
* `RoomInviteId`, which the create response hands back — and so an invite can be looked up
|
||||||
|
* or expired after the fact rather than vanishing with the socket frame.
|
||||||
|
*
|
||||||
|
* The `match` worker owns this schema/migration
|
||||||
|
* (`apps/match/migrations/0001_room_invite.sql`, applied under its own `migrations_table`
|
||||||
|
* so it doesn't clash with the other workers' migrations that share the database).
|
||||||
|
* `ROOM_INVITE_SCHEMA_DDL` mirrors that migration so tests can build the table directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of apps/match/migrations/0001_room_invite.sql). */
|
||||||
|
export const ROOM_INVITE_SCHEMA_DDL: string[] = [
|
||||||
|
// `room_invite_id` is AUTOINCREMENT rather than a bare rowid alias: the id is handed to
|
||||||
|
// the client, and expiring old invites deletes rows, so a reused id would point a
|
||||||
|
// client's stale invite at somebody else's.
|
||||||
|
//
|
||||||
|
// `room_id` is nullable because the invite is: the caller names a room INSTANCE, and one
|
||||||
|
// that has already died (or was never real) leaves the invite with nothing to resolve —
|
||||||
|
// `match` sends it anyway, with a null RoomId, so the row records the same thing.
|
||||||
|
//
|
||||||
|
// `created_at` is epoch SECONDS, like `presence.expires_at` on the same database and for
|
||||||
|
// the same reason: the sweep that will expire these compares it against `Date.now()/1000`
|
||||||
|
// in SQL, and an integer compare needs no parsing. Indexed for that sweep.
|
||||||
|
`CREATE TABLE IF NOT EXISTS room_invite (
|
||||||
|
room_invite_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_player_id INTEGER NOT NULL,
|
||||||
|
to_player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_room_invite_created ON room_invite (created_at)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One invite as the client reads it — PascalCase, and the whole of what `POST /invite`
|
||||||
|
* answers with.
|
||||||
|
*/
|
||||||
|
export interface RoomInvite {
|
||||||
|
RoomInviteId: number
|
||||||
|
FromPlayerId: number
|
||||||
|
ToPlayerId: number
|
||||||
|
RoomId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomInviteRow {
|
||||||
|
room_invite_id: number
|
||||||
|
from_player_id: number
|
||||||
|
to_player_id: number
|
||||||
|
room_id: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLUMNS = `room_invite_id, from_player_id, to_player_id, room_id`
|
||||||
|
|
||||||
|
const nowSeconds = () => Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an invite from `fromPlayerId` to `toPlayerId` for a room, returning it as the
|
||||||
|
* client reads it back. `roomId` is null when the caller's room instance didn't resolve.
|
||||||
|
*
|
||||||
|
* The caller still has to deliver the invite (the `MessageReceived` frame); this only
|
||||||
|
* mints the row and its id.
|
||||||
|
*/
|
||||||
|
export async function createRoomInvite(
|
||||||
|
db: D1Database,
|
||||||
|
fromPlayerId: number,
|
||||||
|
toPlayerId: number,
|
||||||
|
roomId: number | null
|
||||||
|
): Promise<RoomInvite | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO room_invite (from_player_id, to_player_id, room_id, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
RETURNING ${SELECT_COLUMNS}`
|
||||||
|
)
|
||||||
|
.bind(fromPlayerId, toPlayerId, roomId, nowSeconds())
|
||||||
|
.first<RoomInviteRow>()
|
||||||
|
|
||||||
|
if (!row) return null
|
||||||
|
return {
|
||||||
|
RoomInviteId: row.room_invite_id,
|
||||||
|
FromPlayerId: row.from_player_id,
|
||||||
|
ToPlayerId: row.to_player_id,
|
||||||
|
RoomId: row.room_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user