mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add interactions (cheer/faves)
This commit is contained in:
@@ -384,6 +384,9 @@ const app = new Hono<App>({ strict: false })
|
|||||||
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
|
.get('/api/challenge/v2/getCurrent', (c) => c.json({})) // TODO: hydrate from JSON/weeklychallenge.json
|
||||||
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
.get('/api/announcement/v1/get', (c) => c.json([])) // TODO: hydrate from JSON/announcements.json
|
||||||
|
|
||||||
|
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||||||
|
.post('/api/gamesight/event', (c) => c.body(null, 200))
|
||||||
|
|
||||||
// ---- Subscription ---------------------------------------------------------
|
// ---- Subscription ---------------------------------------------------------
|
||||||
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
.post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) =>
|
||||||
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||||
|
|||||||
@@ -28,4 +28,10 @@ const app = new Hono<App>()
|
|||||||
// Periodic session heartbeat. Same deal — accept and ack with 200.
|
// Periodic session heartbeat. Same deal — accept and ack with 200.
|
||||||
.post('/data/heartbeat', (c) => c.body(null, 200))
|
.post('/data/heartbeat', (c) => c.body(null, 200))
|
||||||
|
|
||||||
|
// Analytics identify call (player/device identification). Accept and ack.
|
||||||
|
.post('/identify', (c) => c.body(null, 200))
|
||||||
|
|
||||||
|
// Generic analytics HTTP API sink. Accept and ack.
|
||||||
|
.post('/httpapi', (c) => c.body(null, 200))
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
+31
-11
@@ -31,7 +31,7 @@ const DEFAULT_GET_PLAYER = [
|
|||||||
vrMovementMode: 1,
|
vrMovementMode: 1,
|
||||||
roomInstance: null,
|
roomInstance: null,
|
||||||
isOnline: true,
|
isOnline: true,
|
||||||
appVersion: '20210129',
|
appVersion: '20230302',
|
||||||
platform: 0,
|
platform: 0,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -88,6 +88,14 @@ interface Presence {
|
|||||||
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
|
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
|
||||||
const PRESENCE_TTL = 900
|
const PRESENCE_TTL = 900
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game build version reported in presence. Like the reference servers (FemRec
|
||||||
|
* `ServerConfig.GameVersion`, 2025 `HeartbeatDB`), this is a server-side
|
||||||
|
* constant — the client doesn't supply it, and an empty value breaks the
|
||||||
|
* client's presence/version handling. Matches our target 2023 client build.
|
||||||
|
*/
|
||||||
|
const GAME_VERSION = '20230302'
|
||||||
|
|
||||||
const presenceKey = (id: number) => `presence:${id}`
|
const presenceKey = (id: number) => `presence:${id}`
|
||||||
|
|
||||||
/** Persist the player's presence (room instance + status), refreshing the TTL. */
|
/** Persist the player's presence (room instance + status), refreshing the TTL. */
|
||||||
@@ -111,7 +119,7 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
deviceClass: prev?.deviceClass ?? 0,
|
deviceClass: prev?.deviceClass ?? 0,
|
||||||
vrMovementMode: prev?.vrMovementMode ?? 1,
|
vrMovementMode: prev?.vrMovementMode ?? 1,
|
||||||
platform: prev?.platform ?? 0,
|
platform: prev?.platform ?? 0,
|
||||||
appVersion: prev?.appVersion ?? '',
|
appVersion: prev?.appVersion || GAME_VERSION,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +154,7 @@ function dormRoomInstance() {
|
|||||||
photonRegion: 'us',
|
photonRegion: 'us',
|
||||||
photonRegionId: 'us',
|
photonRegionId: 'us',
|
||||||
photonRoomId: DORM_PHOTON_ROOM_ID,
|
photonRoomId: DORM_PHOTON_ROOM_ID,
|
||||||
name: 'DormRoom',
|
name: '^DormRoom',
|
||||||
maxCapacity: 4,
|
maxCapacity: 4,
|
||||||
isFull: false,
|
isFull: false,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
@@ -167,10 +175,21 @@ function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance {
|
|||||||
| undefined
|
| undefined
|
||||||
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
||||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? 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 {
|
return {
|
||||||
roomInstanceId: 1,
|
roomInstanceId: roomId,
|
||||||
roomId: num(room.RoomId, 1),
|
roomId,
|
||||||
subRoomId: num(sub?.SubRoomId, 0),
|
subRoomId: num(sub?.SubRoomId, 1),
|
||||||
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
||||||
location: str(sub?.UnitySceneId),
|
location: str(sub?.UnitySceneId),
|
||||||
dataBlob: str(sub?.DataBlob),
|
dataBlob: str(sub?.DataBlob),
|
||||||
@@ -179,8 +198,8 @@ function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance {
|
|||||||
roomCode: '',
|
roomCode: '',
|
||||||
photonRegion: 'us',
|
photonRegion: 'us',
|
||||||
photonRegionId: 'us',
|
photonRegionId: 'us',
|
||||||
photonRoomId: crypto.randomUUID(),
|
photonRoomId,
|
||||||
name: str(room.Name),
|
name,
|
||||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||||
isFull: false,
|
isFull: false,
|
||||||
isPrivate: isPrivate || room.IsDorm === true,
|
isPrivate: isPrivate || room.IsDorm === true,
|
||||||
@@ -261,7 +280,7 @@ const app = new Hono<App>()
|
|||||||
vrMovementMode: p?.vrMovementMode ?? 1,
|
vrMovementMode: p?.vrMovementMode ?? 1,
|
||||||
roomInstance: p?.roomInstance ?? null,
|
roomInstance: p?.roomInstance ?? null,
|
||||||
isOnline: p?.roomInstance != null,
|
isOnline: p?.roomInstance != null,
|
||||||
appVersion: p?.appVersion ?? '',
|
appVersion: p?.appVersion || GAME_VERSION,
|
||||||
platform: p?.platform ?? 0,
|
platform: p?.platform ?? 0,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -295,7 +314,8 @@ const app = new Hono<App>()
|
|||||||
if (hb.deviceClass !== undefined) presence.deviceClass = hb.deviceClass
|
if (hb.deviceClass !== undefined) presence.deviceClass = hb.deviceClass
|
||||||
if (hb.vrMovementMode !== undefined) presence.vrMovementMode = hb.vrMovementMode
|
if (hb.vrMovementMode !== undefined) presence.vrMovementMode = hb.vrMovementMode
|
||||||
if (hb.platform !== undefined) presence.platform = hb.platform
|
if (hb.platform !== undefined) presence.platform = hb.platform
|
||||||
if (hb.appVersion != null) presence.appVersion = hb.appVersion
|
if (hb.appVersion) presence.appVersion = hb.appVersion
|
||||||
|
if (!presence.appVersion) presence.appVersion = GAME_VERSION
|
||||||
await setPresence(c, id, presence)
|
await setPresence(c, id, presence)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +326,7 @@ const app = new Hono<App>()
|
|||||||
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
||||||
roomInstance: presence?.roomInstance ?? null,
|
roomInstance: presence?.roomInstance ?? null,
|
||||||
isOnline: presence?.roomInstance != null,
|
isOnline: presence?.roomInstance != null,
|
||||||
appVersion: presence?.appVersion ?? hb.appVersion ?? '',
|
appVersion: presence?.appVersion || hb.appVersion || GAME_VERSION,
|
||||||
platform: presence?.platform ?? hb.platform ?? 0,
|
platform: presence?.platform ?? hb.platform ?? 0,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe('public endpoints', () => {
|
|||||||
expect(players[0]).toMatchObject({
|
expect(players[0]).toMatchObject({
|
||||||
playerId: 99,
|
playerId: 99,
|
||||||
isOnline: false,
|
isOnline: false,
|
||||||
appVersion: '',
|
appVersion: '20230302',
|
||||||
roomInstance: null,
|
roomInstance: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -105,7 +105,7 @@ describe('public endpoints', () => {
|
|||||||
const res = await exports.default.fetch(`${ORIGIN}/player`)
|
const res = await exports.default.fetch(`${ORIGIN}/player`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||||
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: '20210129' })
|
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: '20230302' })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /goto/none returns the offline dorm', async () => {
|
test('POST /goto/none returns the offline dorm', async () => {
|
||||||
@@ -117,7 +117,7 @@ describe('public endpoints', () => {
|
|||||||
}
|
}
|
||||||
expect(body.errorCode).toBe(0)
|
expect(body.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
name: 'DormRoom',
|
name: '^DormRoom',
|
||||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
})
|
})
|
||||||
@@ -139,7 +139,7 @@ describe('public endpoints', () => {
|
|||||||
expect(body.errorCode).toBe(0)
|
expect(body.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
roomId: 2,
|
roomId: 2,
|
||||||
name: 'RecCenter',
|
name: '^RecCenter',
|
||||||
location: RECCENTER_SCENE,
|
location: RECCENTER_SCENE,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
})
|
})
|
||||||
@@ -163,7 +163,7 @@ describe('public endpoints', () => {
|
|||||||
}
|
}
|
||||||
expect(body.errorCode).toBe(0)
|
expect(body.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
name: 'DormRoom',
|
name: '^DormRoom',
|
||||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
})
|
})
|
||||||
@@ -206,7 +206,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
expect(body.errorCode).toBe(0)
|
expect(body.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
name: 'DormRoom',
|
name: '^DormRoom',
|
||||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
roomId: 1,
|
roomId: 1,
|
||||||
@@ -221,14 +221,26 @@ describe('auth-gated endpoints', () => {
|
|||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
roomInstance: { roomId: number; isPrivate: boolean; name: string; location: string }
|
roomInstance: {
|
||||||
|
roomId: number
|
||||||
|
roomInstanceId: number
|
||||||
|
isPrivate: boolean
|
||||||
|
name: string
|
||||||
|
location: string
|
||||||
|
photonRoomId: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
roomId: 2,
|
roomId: 2,
|
||||||
name: 'RecCenter',
|
// 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,
|
location: RECCENTER_SCENE,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
})
|
})
|
||||||
|
// Private instances get a unique Photon room id; public share `rec.<roomId>`.
|
||||||
|
expect(body.roomInstance.photonRoomId.startsWith('rec.2')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /matchmake/:room 401s without a token', async () => {
|
test('POST /matchmake/:room 401s without a token', async () => {
|
||||||
@@ -248,7 +260,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
expect(body.errorCode).toBe(0)
|
expect(body.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
name: 'DormRoom',
|
name: '^DormRoom',
|
||||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
roomId: 1,
|
roomId: 1,
|
||||||
@@ -267,7 +279,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
}
|
}
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
roomId: 2,
|
roomId: 2,
|
||||||
name: 'RecCenter',
|
name: '^RecCenter',
|
||||||
location: RECCENTER_SCENE,
|
location: RECCENTER_SCENE,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
})
|
})
|
||||||
@@ -351,7 +363,7 @@ describe('auth-gated endpoints', () => {
|
|||||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
|
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
|
||||||
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
|
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
|
||||||
expect(hb.isOnline).toBe(true)
|
expect(hb.isOnline).toBe(true)
|
||||||
expect(hb.roomInstance?.name).toBe('DormRoom')
|
expect(hb.roomInstance?.name).toBe('^DormRoom')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /player?id reports stored presence per id', async () => {
|
test('GET /player?id reports stored presence per id', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Per-player interaction state with a room (cheered/favorited + last visit).
|
||||||
|
-- One row per (player, room); cheer/favorite are toggled in place.
|
||||||
|
CREATE TABLE IF NOT EXISTS interaction (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
cheered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_visited_at TEXT,
|
||||||
|
PRIMARY KEY (player_id, room_id)
|
||||||
|
);
|
||||||
@@ -22,6 +22,16 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms (room_id)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms (room_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON rooms (name_lower)`,
|
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON rooms (name_lower)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_account_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_account_id)`,
|
||||||
|
// Per-player interaction state with a room (cheered/favorited + last visit).
|
||||||
|
// One row per (player, room); cheer/favorite are toggled in place.
|
||||||
|
`CREATE TABLE IF NOT EXISTS interaction (
|
||||||
|
player_id INTEGER NOT NULL,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
cheered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
favorited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_visited_at TEXT,
|
||||||
|
PRIMARY KEY (player_id, room_id)
|
||||||
|
)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||||||
@@ -71,6 +81,77 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom
|
|||||||
return parseAll(results)
|
return parseAll(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A player's interaction state with a room. */
|
||||||
|
export interface Interaction {
|
||||||
|
Cheered: boolean
|
||||||
|
Favorited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionRow {
|
||||||
|
cheered: number
|
||||||
|
favorited: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const toInteraction = (row: InteractionRow | null): Interaction => ({
|
||||||
|
Cheered: row?.cheered === 1,
|
||||||
|
Favorited: row?.favorited === 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Read a player's interaction with a room (defaults to all-false if none). */
|
||||||
|
export async function getInteraction(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
roomId: number
|
||||||
|
): Promise<Interaction> {
|
||||||
|
return toInteraction(
|
||||||
|
await db
|
||||||
|
.prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2')
|
||||||
|
.bind(playerId, roomId)
|
||||||
|
.first<InteractionRow>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert+toggle a single boolean column, returning the resulting interaction. */
|
||||||
|
async function toggleInteraction(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
roomId: number,
|
||||||
|
column: 'cheered' | 'favorited'
|
||||||
|
): Promise<Interaction> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
// First interaction defaults the toggled column to 1; subsequent calls flip it.
|
||||||
|
return toInteraction(
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at)
|
||||||
|
VALUES (?1, ?2, 1, ?3)
|
||||||
|
ON CONFLICT(player_id, room_id)
|
||||||
|
DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3
|
||||||
|
RETURNING cheered, favorited`
|
||||||
|
)
|
||||||
|
.bind(playerId, roomId, now)
|
||||||
|
.first<InteractionRow>()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the player's cheer on a room, returning the resulting interaction. */
|
||||||
|
export async function toggleCheer(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
roomId: number
|
||||||
|
): Promise<Interaction> {
|
||||||
|
return toggleInteraction(db, playerId, roomId, 'cheered')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle the player's favorite on a room, returning the resulting interaction. */
|
||||||
|
export async function toggleFavorite(
|
||||||
|
db: D1Database,
|
||||||
|
playerId: number,
|
||||||
|
roomId: number
|
||||||
|
): Promise<Interaction> {
|
||||||
|
return toggleInteraction(db, playerId, roomId, 'favorited')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search-tag aliases: a queried `#tag` also matches these stored tag names.
|
* Search-tag aliases: a queried `#tag` also matches these stored tag names.
|
||||||
* The client's pinned filters don't always match how rooms are tagged (e.g. it
|
* The client's pinned filters don't always match how rooms are tagged (e.g. it
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { validateAndGetAccountId } from './jwt'
|
import { validateAndGetAccountId } from './jwt'
|
||||||
import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds, searchRooms } from './rooms-db'
|
import {
|
||||||
|
getInteraction,
|
||||||
|
getRoomById,
|
||||||
|
getRoomByName,
|
||||||
|
getRoomsByCreator,
|
||||||
|
getRoomsByIds,
|
||||||
|
searchRooms,
|
||||||
|
toggleCheer,
|
||||||
|
toggleFavorite,
|
||||||
|
} from './rooms-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -141,6 +150,36 @@ const app = new Hono<App>()
|
|||||||
.get('/rooms/ownedby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
|
.get('/rooms/ownedby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
|
||||||
.get('/rooms/createdby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
|
.get('/rooms/createdby/me', async (c) => c.json(await getRoomsByCreator(c.env.DB, await ownerId(c))))
|
||||||
|
|
||||||
|
// The current player's interaction state with a room (cheered/favorited/last
|
||||||
|
// visited), read from the `interaction` table.
|
||||||
|
.get('/rooms/:roomId{[0-9]+}/interactionby/me', async (c) => {
|
||||||
|
const interaction = await getInteraction(
|
||||||
|
c.env.DB,
|
||||||
|
await ownerId(c),
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle the player's cheer/favorite on a room. Both are PUTs that flip the
|
||||||
|
// stored flag and return the updated interaction (matches the C#).
|
||||||
|
.put('/rooms/:roomId{[0-9]+}/interactionby/me/cheer', async (c) => {
|
||||||
|
const interaction = await toggleCheer(
|
||||||
|
c.env.DB,
|
||||||
|
await ownerId(c),
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
})
|
||||||
|
.put('/rooms/:roomId{[0-9]+}/interactionby/me/favorite', async (c) => {
|
||||||
|
const interaction = await toggleFavorite(
|
||||||
|
c.env.DB,
|
||||||
|
await ownerId(c),
|
||||||
|
Number.parseInt(c.req.param('roomId'), 10)
|
||||||
|
)
|
||||||
|
return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
|
||||||
|
})
|
||||||
|
|
||||||
// Single room by id. 404 when the room isn't in D1 (matches the C#). Ignores
|
// Single room by id. 404 when the room isn't in D1 (matches the C#). Ignores
|
||||||
// the include/unityAsset* query params, same as the C#.
|
// the include/unityAsset* query params, same as the C#.
|
||||||
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
.get('/rooms/:roomId{[0-9]+}', async (c) => {
|
||||||
|
|||||||
@@ -155,4 +155,36 @@ describe('rooms endpoints', () => {
|
|||||||
expect(body.Permissions.length).toBeGreaterThan(0)
|
expect(body.Permissions.length).toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('interaction: defaults to false, cheer/favorite toggle and persist', async () => {
|
||||||
|
type Interaction = { Cheered: boolean; Favorited: boolean; LastVisitedAt: string }
|
||||||
|
const headers = await bearer('555')
|
||||||
|
const get = async () =>
|
||||||
|
(await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me`, { headers })
|
||||||
|
).json()) as Interaction
|
||||||
|
const put = async (action: 'cheer' | 'favorite') =>
|
||||||
|
(await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/${action}`, { method: 'PUT', headers })
|
||||||
|
).json()) as Interaction
|
||||||
|
|
||||||
|
// No row yet → both false.
|
||||||
|
expect(await get()).toMatchObject({ Cheered: false, Favorited: false })
|
||||||
|
|
||||||
|
// Cheer on, then favorite on.
|
||||||
|
expect(await put('cheer')).toMatchObject({ Cheered: true, Favorited: false })
|
||||||
|
expect(await put('favorite')).toMatchObject({ Cheered: true, Favorited: true })
|
||||||
|
// Persisted across a fresh GET.
|
||||||
|
expect(await get()).toMatchObject({ Cheered: true, Favorited: true })
|
||||||
|
|
||||||
|
// Toggling again flips back.
|
||||||
|
expect(await put('cheer')).toMatchObject({ Cheered: false, Favorited: true })
|
||||||
|
|
||||||
|
// Scoped per player — a different account starts fresh.
|
||||||
|
const other = await bearer('556')
|
||||||
|
const otherGet = (await (
|
||||||
|
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me`, { headers: other })
|
||||||
|
).json()) as Interaction
|
||||||
|
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user