/* Galvanic Corrosion - Rec Room custom server for communities. Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { Redis } from "../../db.ts"; import { RootPath } from "./baseimages.ts"; import { BuiltinRoom, RoomAccessibility, RoomDetails, RoomState } from "./roomtypes.ts"; import { RoomFetch } from "./room.ts"; import { Profile } from "../profiles.ts"; import Logging from "@proxnet/undead-logging"; const log = new Logging("Rooms"); const rooms = JSON.parse(Deno.readTextFileSync(`${RootPath}/res/rooms.json`)) as BuiltinRoom[]; class RoomsBase { readonly roomMetaKeys = { // hash keys RoomId: "id", Name: "name", Description: "desc", CreatorPlayerId: "creatorId", ImageName: "imagename", State: "state", Accessibility: "access", SupportsLevelVoting: "levelvoting", IsAGRoom: "isagroom", IsDormRoom: "isdorm", CloningAllowed: "cloneable", SupportsScreens: "can-screen", SupportsWalkVR: "can-walkvr", SupportsTeleportVR: "can-televr", AllowsJuniors: "juniors", RoomWarningMask: "warningmask", CustomRoomWarning: "warning", DisableMicAutoMute: "disableautomute" } readonly subroomMetaKeys = { // hash keys Name: "name", RoomSceneLocationId: "location", IsSandbox: "issandbox", CanMatchmakeInto: "matchmakeable", RoomSceneId: "sceneid", DataBlobName: "datablob", MaxPlayers: "playercap", DataModifiedAt: "modifiedat" } readonly roomRootKeys = { CheerCount: "cheers", // string CheerPids: "cheers-players", // set VisitCount: "visits", // string FavoriteCount: "favorites", // string FavoritePids: "favorites-players", // set Subrooms: "subrooms", // set Meta: "roommeta" // hash } readonly subroomRootKeys = { Meta: "scenemeta" } readonly miscKeys = { BuiltinGenerated: "builtinrooms-done", AGRooms: "agrooms" } getAllBuiltinRooms() { return rooms; } async get(id: number) { try { return await new RoomFetch({ roomId: id }).fetch(); } catch { return null; } } async getByName(name: string) { try { return await new RoomFetch({ roomName: name }).fetch(); } catch { return null; } } async getAllBuiltinRoomGenerations() { const ids = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Rooms.Root, this.miscKeys.AGRooms)); const parsedIds = ids.map(val => parseInt(val)).filter(val => !isNaN(val)); return (await Promise.all(parsedIds.map(id => this.get(id)))).filter(val => val !== null); } async #getAvailableRoomId() { let id = Math.round(Math.random() * Math.pow(2, 31)); while ((await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Rooms.Root, id.toString(), this.roomRootKeys.Meta))) >= 1) id = await this.#getAvailableRoomId(); return id; } async #getAvailableSubRoomId(roomid: number) { let id = Math.round(Math.random() * Math.pow(2, 31)); while ((await Redis.Database.exists( Redis.buildKey( Redis.KeyGroups.Rooms.Root, roomid.toString(), this.roomRootKeys.Subrooms, id.toString(), this.subroomRootKeys.Meta ))) >= 1) id = await this.#getAvailableSubRoomId(roomid); return id; } async #setRoom(details: RoomDetails) { const rootKey = Redis.buildKey(Redis.KeyGroups.Rooms.Root, details.Room.RoomId.toString()); const roomMetaRootKey = Redis.buildKey(rootKey, this.roomRootKeys.Meta); await Promise.all([ Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.RoomId, details.Room.RoomId), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.Name, details.Room.Name), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.Description, details.Room.Description), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.CreatorPlayerId, details.Room.CreatorPlayerId), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.ImageName, details.Room.ImageName), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.State, details.Room.State), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.Accessibility, details.Room.Accessibility), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.SupportsLevelVoting, `${details.Room.SupportsLevelVoting}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.IsAGRoom, `${details.Room.IsAGRoom}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.IsDormRoom, `${details.Room.IsDormRoom}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.CloningAllowed, `${details.Room.CloningAllowed}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.SupportsScreens, `${details.Room.SupportsScreens}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.SupportsWalkVR, `${details.Room.SupportsWalkVR}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.SupportsTeleportVR, `${details.Room.SupportsTeleportVR}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.AllowsJuniors, `${details.Room.AllowsJuniors}`), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.RoomWarningMask, details.Room.RoomWarningMask), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.CustomRoomWarning, details.Room.CustomRoomWarning), Redis.Database.hset(roomMetaRootKey, this.roomMetaKeys.DisableMicAutoMute, `${details.Room.DisableMicAutoMute ? details.Room.DisableMicAutoMute : 'null'}`), ]); for (const subroom of details.Scenes) { const newSubId = await this.#getAvailableSubRoomId(details.Room.RoomId); const subRootMetaKey = Redis.buildKey( Redis.KeyGroups.Rooms.Root, details.Room.RoomId.toString(), this.roomRootKeys.Subrooms, newSubId.toString(), this.subroomRootKeys.Meta ); await Promise.all([ Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.RoomSceneLocationId, subroom.RoomSceneLocationId), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.Name, subroom.Name), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.IsSandbox, `${subroom.IsSandbox}`), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.DataBlobName, subroom.DataBlobName), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.MaxPlayers, subroom.MaxPlayers), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.CanMatchmakeInto, `${subroom.CanMatchmakeInto}`), Redis.Database.hset(subRootMetaKey, this.subroomMetaKeys.DataModifiedAt, new Date().toISOString()), ]); await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Rooms.Root, details.Room.RoomId.toString(), this.roomRootKeys.Subrooms), newSubId); } await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Room_Names, details.Room.Name), details.Room.RoomId); } async cloneRoom(roomid: number, newname: string, newowner: Profile) { const canBeClonedRaw = await Redis.Database.hget(Redis.buildKey( Redis.KeyGroups.Rooms.Root, roomid.toString(), Rooms.roomRootKeys.Meta ), Rooms.roomMetaKeys.CloningAllowed); if (!canBeClonedRaw) return null; let canBeCloned = null; try { canBeCloned = JSON.parse(canBeClonedRaw) as boolean; } catch { log.d(`Cloneroom ${roomid}: parse error`); return null; } if (!canBeCloned) { log.d(`Cloneroom ${roomid}: cannot be cloned`); return null; } const beforeRoom = await Rooms.get(roomid); // room must exist if (!beforeRoom || !beforeRoom.Room.CloningAllowed) return null; // room must be cloneable if (beforeRoom.Room.Name !== 'DormRoom' && await Rooms.getByName(newname)) return null; // room name cannot be taken const newId = await this.#getAvailableRoomId(); beforeRoom.Room.CreatorPlayerId = newowner.getId(); beforeRoom.Room.RoomId = newId; for (const subroom of beforeRoom.Scenes) subroom.RoomId = newId; await Rooms.#setRoom(beforeRoom); return beforeRoom; } async getProfileDormDefault(player: Profile) { const unparsedId = await Redis.Database.hget(Redis.buildKey( Redis.KeyGroups.Rooms.Root, Redis.KeyGroups.Rooms.PlayerDorms ), player.getId().toString()); if (unparsedId) { log.d(`Unparsed dorm ID for profile ${player.getId()}: ${unparsedId}`); const parsedId = parseInt(unparsedId); if (isNaN(parsedId)) { log.d(`Returning new dorm for profile ${player.getId()}`); return await Rooms.get(parsedId); } } const newDorm = await this.generateNewDorm(player); await this.#setRoom(newDorm); log.d(`New dorm for ${player.getId()} existed`); if (!newDorm) return null; log.d(`New dorm for ${player.getId()} was not null`); await Redis.Database.hset(Redis.buildKey( Redis.KeyGroups.Rooms.Root, Redis.KeyGroups.Rooms.PlayerDorms ), player.getId().toString(), newDorm.Room.RoomId); return newDorm; } async generateNewDorm(player: Profile) { const id = await this.#getAvailableRoomId(); const basedorm: RoomDetails = { Room: { RoomId: id, Name: `DormRoom`, Description: "Your private room.", CreatorPlayerId: player.getId(), ImageName: "DefaultProfileImage.png", State: RoomState.Active, Accessibility: RoomAccessibility.Private, SupportsLevelVoting: false, IsAGRoom: false, IsDormRoom: true, CloningAllowed: false, SupportsScreens: true, SupportsTeleportVR: true, SupportsWalkVR: true, AllowsJuniors: true, RoomWarningMask: 0, CustomRoomWarning: "", DisableMicAutoMute: false }, Scenes: [ { RoomId: id, RoomSceneId: 1, Name: "Home", RoomSceneLocationId: "76d98498-60a1-430c-ab76-b54a29b7a163", IsSandbox: true, CanMatchmakeInto: true, MaxPlayers: 4, DataBlobName: "", DataModifiedAt: new Date().toISOString() } ], CoOwners: [], InvitedCoOwners: [], Hosts: [], InvitedHosts: [], CheerCount: 0, VisitCount: 0, FavoriteCount: 0, Tags: [] } return basedorm; } async generateBuiltinRooms() { if ((await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Rooms.Root, this.miscKeys.BuiltinGenerated))) !== null) return true; for (const builtinRoom of rooms) { if (builtinRoom.Name == 'DormRoom') continue; const newId = await this.#getAvailableRoomId(); await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Rooms.Root, this.miscKeys.AGRooms), newId); const roomDets: RoomDetails = { Room: { Name: builtinRoom.Name, RoomId: newId, Description: builtinRoom.Description, CreatorPlayerId: 1, ImageName: `${builtinRoom.Name}.png`, State: RoomState.Active, Accessibility: builtinRoom.Accessibility, SupportsLevelVoting: builtinRoom.SupportsLevelVoting, IsAGRoom: true, IsDormRoom: builtinRoom.Name == 'DormRoom', CloningAllowed: builtinRoom.Name == 'DormRoom' ? true : builtinRoom.CloningAllowed, SupportsScreens: builtinRoom.SupportsScreens, SupportsWalkVR: builtinRoom.SupportsWalkVR, SupportsTeleportVR: builtinRoom.SupportsTeleportVR, AllowsJuniors: true, RoomWarningMask: 0, CustomRoomWarning: "" }, Scenes: [], CoOwners: [], InvitedCoOwners: [], Hosts: [], InvitedHosts: [], CheerCount: 0, FavoriteCount: 0, VisitCount: 0, Tags: [] } for (const subroom of builtinRoom.Scenes) { const newSubroomId = await this.#getAvailableSubRoomId(newId); roomDets.Scenes.push({ RoomSceneId: newSubroomId, RoomId: newId, RoomSceneLocationId: subroom.RoomSceneLocationId, Name: subroom.Name, IsSandbox: subroom.IsSandbox, DataBlobName: "", MaxPlayers: subroom.MaxPlayers, CanMatchmakeInto: subroom.CanMatchmakeInto ? subroom.CanMatchmakeInto : true, DataModifiedAt: new Date().toISOString() }); } await this.#setRoom(roomDets); } Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Rooms.Root, this.miscKeys.BuiltinGenerated), "1"); return false; } async getIdFromName(name: string) { const unparsedId = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Room_Names, name)); if (!unparsedId) return null; const parsedId = parseInt(unparsedId); if (isNaN(parsedId)) return null; return parsedId; } async getNameFromId(id: number) { const name = await Redis.Database.hget(Redis.buildKey( Redis.KeyGroups.Rooms.Root, id.toString(), this.roomRootKeys.Meta ), this.roomMetaKeys.Name); if (!name) return null; return name; } async getSubroomIdsFromRoom(id: number): Promise; async getSubroomIdsFromRoom(id: number, stringify: false): Promise; async getSubroomIdsFromRoom(id: number, stringify: boolean | undefined = false): Promise { const ids = await Redis.Database.smembers(Redis.buildKey( Redis.KeyGroups.Rooms.Root, id.toString(), this.roomRootKeys.Subrooms )); const parsedIds = ids.map(val => parseInt(val)).filter(val => !isNaN(val)); if (!stringify) return parsedIds; else return parsedIds.map(val => val.toString()); } getSubroomNameFromId(room: RoomDetails, subroomId: number) { const subroom = room.Scenes.find(scene => scene.RoomSceneId == subroomId); if (subroom) return subroom.Name; else return null; } } const Rooms = new RoomsBase(); export { rooms as BuiltinRooms }; export default Rooms;