mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Currency, storefronts, purchasing (#12)
And a few other minor things, but primarily, the balance table exists and also consumable/inventory table.
This commit is contained in:
+58
-12
@@ -2,8 +2,10 @@ import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
canManageRoom,
|
||||
createRoomInstance,
|
||||
deleteExpiredPresence,
|
||||
deletePresence,
|
||||
getAccount,
|
||||
getExpiredPresenceInstanceIds,
|
||||
getJoinableInstance,
|
||||
@@ -178,6 +180,15 @@ const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
|
||||
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||
const NO_SUCH_ROOM = 20
|
||||
|
||||
/**
|
||||
* The sentinel room-instance id the `auth` worker seeds a brand-new player's
|
||||
* Orientation presence with (see auth's `placeNewPlayerInOrientation`). The client
|
||||
* fires a spurious `player/logout` right after that seed, so logout must NOT clear
|
||||
* presence while it still points at Orientation — doing so wipes the seed and
|
||||
* bounces the new player to the dorm.
|
||||
*/
|
||||
const ORIENTATION_INSTANCE_ID = -2
|
||||
|
||||
/**
|
||||
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
|
||||
* by every dorm entry point and the presence heartbeat so the client's local
|
||||
@@ -301,10 +312,22 @@ async function resolveRoomInstance(
|
||||
if (!room) return null
|
||||
|
||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||
// Never place the player back into the instance they're already in: the client
|
||||
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
||||
// your current instance (e.g. the only public instance of a room you're already in)
|
||||
// returns the same id and hangs the client mid-join. Exclude it from the join
|
||||
// search, which pushes them to another live instance if one exists or forces a
|
||||
// fresh one below. (Only the public path reuses instances, so only it needs the
|
||||
// read; a private matchmake always gets a fresh instance.)
|
||||
const currentInstanceId = isPrivate
|
||||
? undefined
|
||||
: (await getPresence<RoomInstance>(c.env.DB, ownerId))?.roomInstance?.roomInstanceId
|
||||
// Reuse an existing joinable public instance *of the same subroom* — subrooms are
|
||||
// separate places, so joining one must never land you in another. 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, f.subRoomId)
|
||||
let instance = isPrivate
|
||||
? null
|
||||
: await getJoinableInstance(c.env.DB, f.roomId, f.subRoomId, currentInstanceId)
|
||||
if (!instance) {
|
||||
instance = await createRoomInstance(c.env.DB, {
|
||||
ownerAccountId: ownerId,
|
||||
@@ -372,15 +395,36 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// ---- Player presence -----------------------------------------------------
|
||||
// login/exclusivelogin/logout are all no-op acks and MUST NOT touch presence.
|
||||
// The client fires a spurious `player/logout` during the account-creation
|
||||
// bootstrap (right after create_account seeds the new player into Orientation);
|
||||
// deleting presence here wiped that seed and bounced the player to the dorm.
|
||||
// Presence is overwritten by matchmake/goto and expires on its own TTL, so we
|
||||
// don't need to clear it on these lifecycle calls.
|
||||
// login/exclusivelogin are no-op acks and MUST NOT touch presence: the client
|
||||
// fires exclusivelogin when going online, and clearing presence there would bounce
|
||||
// the player to the dorm. Presence is overwritten by matchmake/goto and expires on
|
||||
// its own TTL.
|
||||
.post('/player/login', (c) => c.body(null, 200))
|
||||
.post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 }))
|
||||
.post('/player/logout', (c) => c.body(null, 200))
|
||||
|
||||
// Logout clears the player's presence so they read offline immediately and the
|
||||
// instance they were in frees up (rather than waiting out the presence TTL).
|
||||
//
|
||||
// EXCEPTION: the account-creation bootstrap. The client fires a spurious
|
||||
// `player/logout` right after a new player is seeded into Orientation (the auth
|
||||
// worker writes that presence with instance id -2). Clearing presence there wipes
|
||||
// the seed and bounces the new player to the dorm — so a logout that still points
|
||||
// at Orientation is left as a no-op ack. An unauthenticated logout is also a no-op
|
||||
// (no player to clear).
|
||||
.post('/player/logout', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) {
|
||||
const presence = await getPresence<RoomInstance>(c.env.DB, id)
|
||||
const instanceId = presence?.roomInstance?.roomInstanceId
|
||||
if (presence && instanceId !== ORIENTATION_INSTANCE_ID) {
|
||||
await deletePresence(c.env.DB, id)
|
||||
// The instance they were in lost a player — recompute its fullness so a
|
||||
// full room frees up. No-op for the synthetic dorm/orientation instances.
|
||||
if (instanceId != null) await refreshInstanceFullness(c.env.DB, instanceId)
|
||||
}
|
||||
}
|
||||
return c.body(null, 200)
|
||||
})
|
||||
|
||||
.get('/player', async (c) => {
|
||||
// Returns each requested player's presence. Reads the `id` query param(s);
|
||||
@@ -595,9 +639,9 @@ const app = new Hono<App>()
|
||||
})
|
||||
|
||||
// The room's live instances — the owner's view of active sessions of their room.
|
||||
// Auth-gated (401) and owner-only (403): the caller must be the room's creator.
|
||||
// Unknown room → 404. Returns the bare RoomInstance DTO array (empty when the
|
||||
// room has no live instances).
|
||||
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
||||
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
|
||||
// bare RoomInstance DTO array (empty when the room has no live instances).
|
||||
.get('/room/:roomId{[0-9]+}/instances', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
@@ -605,7 +649,9 @@ const app = new Hono<App>()
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return c.body(null, 404)
|
||||
if (room.CreatorAccountId !== id) return c.body(null, 403)
|
||||
// The room's creator *or* a co-owner (Role 30) may see its live instances —
|
||||
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
||||
|
||||
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
|
||||
})
|
||||
|
||||
@@ -50,6 +50,8 @@ const TEST_ROOMS = [
|
||||
IsDorm: false,
|
||||
Accessibility: 1,
|
||||
CreatorAccountId: 42,
|
||||
// Account 43 is a co-owner (Role 30) — it may view the room's instances too.
|
||||
Roles: [{ AccountId: 43, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
|
||||
SubRooms: [{ SubRoomId: 3, UnitySceneId: RECCENTER_SCENE, MaxPlayers: 8 }],
|
||||
},
|
||||
{
|
||||
@@ -435,31 +437,43 @@ describe('auth-gated endpoints', () => {
|
||||
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) =>
|
||||
test('POST /matchmake/:room reuses a public instance across players; a private one is fresh', async () => {
|
||||
const matchmake = async (sub: string, joinMode?: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/2`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('900')),
|
||||
...(await bearer(sub)),
|
||||
'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()
|
||||
// Two *different* players matchmaking into the same room share the reused
|
||||
// instance (population grouping). Distinct accounts here, since re-matchmaking as
|
||||
// the *same* player deliberately moves them to a fresh instance — see below.
|
||||
const a = await matchmake('900')
|
||||
const b = await matchmake('901')
|
||||
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')
|
||||
const priv = await matchmake('902', '2')
|
||||
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
|
||||
})
|
||||
|
||||
test('re-matchmaking into your current room returns a different instance (id must change)', async () => {
|
||||
// The client keys the room transition off a changing roomInstanceId; handing back
|
||||
// the instance the player is already in hangs their join. RecCenter (cap 12) so
|
||||
// the instance isn't full — the naive "reuse the oldest joinable" would otherwise
|
||||
// return the same id the player already has.
|
||||
const first = await matchmakeInto('2', '950')
|
||||
const second = await matchmakeInto('2', '950')
|
||||
expect(second).not.toBe(first)
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
@@ -722,13 +736,11 @@ describe('auth-gated endpoints', () => {
|
||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||
})
|
||||
|
||||
test('player/login, exclusivelogin and logout all preserve presence', async () => {
|
||||
test('player/login and exclusivelogin preserve presence', async () => {
|
||||
const headers = await bearer('9')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
// None of these lifecycle calls may wipe presence — the client fires a
|
||||
// spurious logout during the account-creation bootstrap, and exclusivelogin
|
||||
// when going online. Clearing here would bounce the player to the dorm.
|
||||
await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers })
|
||||
// These acks must not wipe presence — the client fires exclusivelogin when going
|
||||
// online, and clearing here would bounce the player to the dorm.
|
||||
await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST', headers })
|
||||
await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers })
|
||||
const hb = (await (
|
||||
@@ -740,6 +752,59 @@ describe('auth-gated endpoints', () => {
|
||||
expect(hb.roomInstance?.name).toBe("@Player9's Dorm")
|
||||
})
|
||||
|
||||
test('player/logout clears presence and frees the instance the player was in', async () => {
|
||||
// Fill SoloRoom (cap 1) so its instance is full, then log out.
|
||||
const solo = await matchmakeInto('5', '960')
|
||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(true)
|
||||
|
||||
const headers = await bearer('960')
|
||||
await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers })
|
||||
|
||||
// Presence is gone → the heartbeat reports offline with no room.
|
||||
const hb = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
|
||||
).json()) as { roomInstance: unknown; isOnline: boolean }
|
||||
expect(hb.isOnline).toBe(false)
|
||||
expect(hb.roomInstance).toBeNull()
|
||||
expect(await countPresenceRows(960)).toBe(0)
|
||||
// The instance they left is no longer full.
|
||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||
})
|
||||
|
||||
test('player/logout preserves a new player still in Orientation (account-creation bootstrap)', async () => {
|
||||
// Mirror the auth worker's Orientation seed: presence pointing at instance -2.
|
||||
// The client's spurious bootstrap logout must NOT wipe it, or the new player is
|
||||
// bounced out of Orientation to the dorm.
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 961,
|
||||
roomInstance: { roomInstanceId: -2, roomId: 13, name: '^Orientation' },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: '20230302',
|
||||
expiresAt: nowSeconds() + 800,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
await exports.default.fetch(`${ORIGIN}/player/logout`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('961'),
|
||||
})
|
||||
|
||||
const hb = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('961'),
|
||||
})
|
||||
).json()) as { roomInstance: { roomInstanceId: number } | null; isOnline: boolean }
|
||||
expect(hb.isOnline).toBe(true)
|
||||
expect(hb.roomInstance?.roomInstanceId).toBe(-2)
|
||||
})
|
||||
|
||||
test('GET /player?id reports stored presence per id', async () => {
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||
method: 'POST',
|
||||
@@ -751,11 +816,12 @@ describe('auth-gated endpoints', () => {
|
||||
expect(players[0]).toMatchObject({ playerId: 55, isOnline: true })
|
||||
})
|
||||
|
||||
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/co-owner-only, and lists the room’s instances', async () => {
|
||||
// No token → 401.
|
||||
expect((await exports.default.fetch(`${ORIGIN}/room/3/instances`)).status).toBe(401)
|
||||
|
||||
// Not the owner (room 3 is owned by account 42) → 403.
|
||||
// A valid token but no role on the room (room 3 is owned by account 42, with
|
||||
// account 43 as co-owner) → 403.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||
@@ -785,5 +851,12 @@ describe('auth-gated endpoints', () => {
|
||||
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1)
|
||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
||||
|
||||
// The co-owner (account 43, Role 30) may view the instances too.
|
||||
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||
headers: await bearer('43'),
|
||||
})
|
||||
expect(coOwner.status).toBe(200)
|
||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user