diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 701d047..064f996 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -384,6 +384,9 @@ const app = new Hono({ strict: false }) .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 + // GameSight attribution/analytics event sink. Accept and ack without persisting. + .post('/api/gamesight/event', (c) => c.body(null, 200)) + // ---- Subscription --------------------------------------------------------- .post('/api/CampusCard/v1/UpdateAndGetSubscription', (c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null }) diff --git a/apps/datacollection/src/datacollection.app.ts b/apps/datacollection/src/datacollection.app.ts index 5d8e63b..7f02689 100644 --- a/apps/datacollection/src/datacollection.app.ts +++ b/apps/datacollection/src/datacollection.app.ts @@ -28,4 +28,10 @@ const app = new Hono() // Periodic session heartbeat. Same deal — accept and ack with 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 diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 757ac98..5307e7e 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -31,7 +31,7 @@ const DEFAULT_GET_PLAYER = [ vrMovementMode: 1, roomInstance: null, isOnline: true, - appVersion: '20210129', + appVersion: '20230302', platform: 0, }, ] @@ -88,6 +88,14 @@ interface Presence { /** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */ 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}` /** Persist the player's presence (room instance + status), refreshing the TTL. */ @@ -111,7 +119,7 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance deviceClass: prev?.deviceClass ?? 0, vrMovementMode: prev?.vrMovementMode ?? 1, platform: prev?.platform ?? 0, - appVersion: prev?.appVersion ?? '', + appVersion: prev?.appVersion || GAME_VERSION, }) } @@ -146,7 +154,7 @@ function dormRoomInstance() { photonRegion: 'us', photonRegionId: 'us', photonRoomId: DORM_PHOTON_ROOM_ID, - name: 'DormRoom', + name: '^DormRoom', maxCapacity: 4, isFull: false, isPrivate: true, @@ -167,10 +175,21 @@ function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance { | undefined const str = (v: unknown, fallback = '') => (typeof v === 'string' ? 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 { - roomInstanceId: 1, - roomId: num(room.RoomId, 1), - subRoomId: num(sub?.SubRoomId, 0), + roomInstanceId: roomId, + roomId, + subRoomId: num(sub?.SubRoomId, 1), roomInstanceType: room.IsDorm === true ? 2 : 0, location: str(sub?.UnitySceneId), dataBlob: str(sub?.DataBlob), @@ -179,8 +198,8 @@ function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance { roomCode: '', photonRegion: 'us', photonRegionId: 'us', - photonRoomId: crypto.randomUUID(), - name: str(room.Name), + photonRoomId, + name, maxCapacity: num(sub?.MaxPlayers, 4), isFull: false, isPrivate: isPrivate || room.IsDorm === true, @@ -261,7 +280,7 @@ const app = new Hono() vrMovementMode: p?.vrMovementMode ?? 1, roomInstance: p?.roomInstance ?? null, isOnline: p?.roomInstance != null, - appVersion: p?.appVersion ?? '', + appVersion: p?.appVersion || GAME_VERSION, platform: p?.platform ?? 0, } }) @@ -295,7 +314,8 @@ const app = new Hono() if (hb.deviceClass !== undefined) presence.deviceClass = hb.deviceClass if (hb.vrMovementMode !== undefined) presence.vrMovementMode = hb.vrMovementMode 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) } @@ -306,7 +326,7 @@ const app = new Hono() vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1), roomInstance: 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, }) }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index f8067e1..762b8c8 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -96,7 +96,7 @@ describe('public endpoints', () => { expect(players[0]).toMatchObject({ playerId: 99, isOnline: false, - appVersion: '', + appVersion: '20230302', roomInstance: null, }) }) @@ -105,7 +105,7 @@ describe('public endpoints', () => { const res = await exports.default.fetch(`${ORIGIN}/player`) expect(res.status).toBe(200) 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 () => { @@ -117,7 +117,7 @@ describe('public endpoints', () => { } expect(body.errorCode).toBe(0) expect(body.roomInstance).toMatchObject({ - name: 'DormRoom', + name: '^DormRoom', location: '76d98498-60a1-430c-ab76-b54a29b7a163', isPrivate: true, }) @@ -139,7 +139,7 @@ describe('public endpoints', () => { expect(body.errorCode).toBe(0) expect(body.roomInstance).toMatchObject({ roomId: 2, - name: 'RecCenter', + name: '^RecCenter', location: RECCENTER_SCENE, isPrivate: true, }) @@ -163,7 +163,7 @@ describe('public endpoints', () => { } expect(body.errorCode).toBe(0) expect(body.roomInstance).toMatchObject({ - name: 'DormRoom', + name: '^DormRoom', location: '76d98498-60a1-430c-ab76-b54a29b7a163', isPrivate: true, }) @@ -206,7 +206,7 @@ describe('auth-gated endpoints', () => { } expect(body.errorCode).toBe(0) expect(body.roomInstance).toMatchObject({ - name: 'DormRoom', + name: '^DormRoom', location: '76d98498-60a1-430c-ab76-b54a29b7a163', isPrivate: true, roomId: 1, @@ -221,14 +221,26 @@ describe('auth-gated endpoints', () => { }) expect(res.status).toBe(200) 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({ 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, isPrivate: true, }) + // Private instances get a unique Photon room id; public share `rec.`. + expect(body.roomInstance.photonRoomId.startsWith('rec.2')).toBe(true) }) test('POST /matchmake/:room 401s without a token', async () => { @@ -248,7 +260,7 @@ describe('auth-gated endpoints', () => { } expect(body.errorCode).toBe(0) expect(body.roomInstance).toMatchObject({ - name: 'DormRoom', + name: '^DormRoom', location: '76d98498-60a1-430c-ab76-b54a29b7a163', isPrivate: true, roomId: 1, @@ -267,7 +279,7 @@ describe('auth-gated endpoints', () => { } expect(body.roomInstance).toMatchObject({ roomId: 2, - name: 'RecCenter', + name: '^RecCenter', location: RECCENTER_SCENE, isPrivate: true, }) @@ -351,7 +363,7 @@ describe('auth-gated endpoints', () => { await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers }) ).json()) as { roomInstance: { name: string } | null; isOnline: boolean } 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 () => { diff --git a/apps/rooms/migrations/0003_interaction.sql b/apps/rooms/migrations/0003_interaction.sql new file mode 100644 index 0000000..f5e6029 --- /dev/null +++ b/apps/rooms/migrations/0003_interaction.sql @@ -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) +); diff --git a/apps/rooms/src/rooms-db.ts b/apps/rooms/src/rooms-db.ts index 00828f2..fa3efa3 100644 --- a/apps/rooms/src/rooms-db.ts +++ b/apps/rooms/src/rooms-db.ts @@ -22,6 +22,16 @@ export const SCHEMA_DDL: string[] = [ `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_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). */ @@ -71,6 +81,77 @@ export async function getRoomsByCreator(db: D1Database, accountId: number): Prom 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 { + return toInteraction( + await db + .prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2') + .bind(playerId, roomId) + .first() + ) +} + +/** Upsert+toggle a single boolean column, returning the resulting interaction. */ +async function toggleInteraction( + db: D1Database, + playerId: number, + roomId: number, + column: 'cheered' | 'favorited' +): Promise { + 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() + ) +} + +/** Toggle the player's cheer on a room, returning the resulting interaction. */ +export async function toggleCheer( + db: D1Database, + playerId: number, + roomId: number +): Promise { + 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 { + return toggleInteraction(db, playerId, roomId, 'favorited') +} + /** * 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 diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 9b499eb..2ff7017 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -4,7 +4,16 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' 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 { App } from './context' @@ -141,6 +150,36 @@ const app = new Hono() .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)))) + // 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 // the include/unityAsset* query params, same as the C#. .get('/rooms/:roomId{[0-9]+}', async (c) => { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index aa4cc0f..6a50b4a 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -155,4 +155,36 @@ describe('rooms endpoints', () => { 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 }) + }) })