import Logging from "@proxnet/undead-logging"; import type KV from "../../persistence/kv.ts"; import { type ServerBase } from "../../server.ts"; import { DatabaseRoom, FactoryMode, GalvanicTagDTO, HardwareSupports, RoomAccessibility, RoomDataTypes, RoomState, TagDTO, TagType, WriteMode } from "./RoomDataTypes.ts"; import { SubroomFactory } from "./SubroomFactory.ts"; import { ServerRoomsBase } from "../base.ts"; const log = new Logging("RoomFactory"); const roomDebug = false; interface FactoryOptionsBase { mode: FactoryMode, id: number, name?: string } export interface WriteFactoryOptions extends FactoryOptionsBase { mode: FactoryMode.Write, writeMode: WriteMode, } export interface FetchFactoryOptions extends FactoryOptionsBase { mode: FactoryMode.Fetch } export type FactoryOptions = WriteFactoryOptions | FetchFactoryOptions; export class RoomFactory { static roomsKey = "rooms"; #server: ServerBase; #kv: KV; #roomId: number | undefined; factoryMode: FactoryMode = FactoryMode.Fetch; writeMode: WriteMode = WriteMode.WriteIfFree; #obj: DatabaseRoom | null = null; #subrooms: Set | null = null; #hardwareSupport: Set | null = null; #tags: Set | null = null; #cannotAccessBeforeInitError = new Error("Cannot access properties before initialization"); #cannotWriteBeforeInitError = new Error("Cannot write before initialization"); constructor(server: ServerBase, kv: KV) { this.#server = server; this.#kv = kv; } /** * Initialize the factory. Retrieves the room from the database and populates factory values. * * Does not fetch subroom content, only available subroom IDs. * * When using write mode, values are initialized to defaults. * * Defaults: * - All hardware is supported * - Cloning is not allowed * - Room is not AG or dorm * - State is Moderation_Closed * - Accessibility is Unlisted * - CreatorPlayerId is 1 (Coach) * - Name and Description are empty strings */ async init(options: FactoryOptions) { if (typeof options.id == 'undefined' && typeof options.name == 'undefined') throw new Error("Must specify a room ID or a room name"); if (typeof options.id !== 'undefined') this.#roomId = options.id; else { if (!options.name) throw new Error("Room name must be provided"); const id = await this.#server.Rooms.getIdFromName(options.name); if (id == null) throw new Error("Room name not found"); this.#roomId = id; } const obj = await this.#kv.getKv().get([RoomFactory.roomsKey, this.#roomId, 'meta']); if (options.mode == FactoryMode.Fetch && obj.value == null) return null; else this.#obj = options.mode == FactoryMode.Fetch ? obj.value : { Hardware: new Set(HardwareSupports), Tags: new Set(), CoOwners: new Set(), InvitedCoOwners: new Set(), Hosts: new Set(), InvitedHosts: new Set(), Room: { Name: "", Description: "", CreatorPlayerId: 1, ImageName: "", State: RoomState.Moderation_Closed, Accessibility: RoomAccessibility.Unlisted, SupportsLevelVoting: false, IsAGRoom: false, IsDormRoom: false, CloningAllowed: false, AllowsJuniors: true, RoomWarningMask: 0, CustomRoomWarning: "", DisableMicAutoMute: null } }; const subrooms = await this.#kv.getKv().get>([RoomFactory.roomsKey, this.#roomId, "subrooms"]); if (options.mode == FactoryMode.Write || subrooms.value == null) this.#subrooms = new Set(); else this.#subrooms = subrooms.value; const hardwareSupport = await this.#kv.getKv().get>([RoomFactory.roomsKey, this.#roomId, 'hardware']); if (hardwareSupport.value == null) this.#hardwareSupport = new Set(HardwareSupports); else this.#hardwareSupport = hardwareSupport.value; const tags = await this.#kv.getKv().get>([RoomFactory.roomsKey, this.#roomId, 'tags']); this.#tags = tags.value; this.factoryMode = options.mode; if (options.mode == FactoryMode.Write) this.writeMode = options.writeMode; return this; } async write() { if (typeof this.#roomId !== 'number') throw this.#cannotWriteBeforeInitError; const w = async () => { if (typeof this.#roomId !== 'number') throw this.#cannotWriteBeforeInitError; await Promise.all([ this.#kv.getKv().set([RoomFactory.roomsKey, this.#roomId, 'meta'], this.#obj), this.#kv.getKv().set([RoomFactory.roomsKey, this.#roomId, 'hardware'], this.#hardwareSupport), this.#kv.getKv().set([RoomFactory.roomsKey, this.#roomId, 'tags'], this.#tags), this.#kv.getKv().set([RoomFactory.roomsKey, this.#roomId, "subrooms"], this.#subrooms) ]); if (!this.IsDormRoom) this.#kv.getKv().set([ServerRoomsBase.roomNamesKey, this.Name], this.#roomId); else this.#kv.getKv().set([ServerRoomsBase.playerDormsKey, this.CreatorPlayerId], this.#roomId); this.#server.emit('room.updated', { room: this }); if (roomDebug) log.d(`Room ${this.#roomId} written and emitted`); } if (this.factoryMode == FactoryMode.Fetch) throw new Error("Cannot write in fetch mode"); const key = [RoomFactory.roomsKey, this.#roomId, 'meta']; const val = await this.#kv.getKv().get(key); if (val.value == null) await w(); else { if (this.writeMode == WriteMode.Overwrite) await w(); else throw new Error("Room already exists"); } } async export(): Promise { if (!this.#obj || !this.#roomId) throw this.#cannotAccessBeforeInitError; const obj = this.#obj; const hardwareSupport = this.getHardwareSupport(); const autoTags = this.getTags(); const galvTags = this.getGalvanicTags(); const subroomExports = (await Promise.all( this.getSubrooms().values().map(subroom => this.getSubroom(subroom)) )).map(factory => factory.export()); return { Room: { RoomId: this.#roomId, Name: obj.Room.Name, Description: obj.Room.Description, CreatorPlayerId: obj.Room.CreatorPlayerId, ImageName: obj.Room.ImageName, State: obj.Room.State, Accessibility: obj.Room.Accessibility, SupportsLevelVoting: obj.Room.SupportsLevelVoting, IsAGRoom: obj.Room.IsAGRoom, IsDormRoom: obj.Room.IsDormRoom, CloningAllowed: obj.Room.CloningAllowed, AllowsJuniors: obj.Room.AllowsJuniors, RoomWarningMask: obj.Room.RoomWarningMask, CustomRoomWarning: obj.Room.CustomRoomWarning, DisableMicAutoMute: obj.Room.DisableMicAutoMute, SupportsScreens: hardwareSupport.has('screens'), SupportsWalkVR: hardwareSupport.has('walk_vr'), SupportsTeleportVR: hardwareSupport.has('teleport_vr'), SupportsMobile: hardwareSupport.has('mobile'), SupportsVRLow: hardwareSupport.has('low_vr') }, Scenes: subroomExports, Tags: autoTags.concat(galvTags), CoOwners: [], InvitedCoOwners: [], Hosts: [], InvitedHosts: [], CheerCount: 0, FavoriteCount: 0, VisitCount: await this.getVisitCount() } } setRoomProperties(props: RoomDataTypes.DatabaseRoomContent) { Object.assign(this, props); } getRoomId() { if (!this.#roomId) throw this.#cannotAccessBeforeInitError; return this.#roomId; } getSubrooms() { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; return this.#subrooms; } async getSubroom(id: number) { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; if (!this.#subrooms.has(id)) throw new Error("Subroom not available to this room"); return await new SubroomFactory(this.#server, this.#kv).init({ mode: FactoryMode.Fetch, writeMode: WriteMode.WriteIfFree, id }); } getAvailableSubroomId() { let id = Math.round(Math.random() * Math.pow(2, 31)); if (this.getSubrooms().has(id)) id = this.getAvailableSubroomId(); return id; } async newSubroom(mode?: WriteMode) { return await new SubroomFactory(this.#server, this.#kv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: this.getAvailableSubroomId() }); } addSubroom(id: number) { if (!this.#subrooms) throw this.#cannotAccessBeforeInitError; this.#subrooms.add(id); } get Name() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.Name } set Name(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.Name = data } get Description() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.Description } set Description(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.Description = data } get CreatorPlayerId() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.CreatorPlayerId } set CreatorPlayerId(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.CreatorPlayerId = data } get ImageName() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.ImageName } set ImageName(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.ImageName = data } get State() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.State } set State(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.State = data } get Accessibility() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.Accessibility } set Accessibility(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.Accessibility = data } get SupportsLevelVoting() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.SupportsLevelVoting } set SupportsLevelVoting(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.SupportsLevelVoting = data } get IsAGRoom() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.IsAGRoom } set IsAGRoom(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.IsAGRoom = data } get IsDormRoom() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.IsDormRoom } set IsDormRoom(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.IsDormRoom = data } get CloningAllowed() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.CloningAllowed } set CloningAllowed(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.CloningAllowed = data } get AllowsJuniors() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.AllowsJuniors } set AllowsJuniors(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.AllowsJuniors = data } get RoomWarningMask() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.RoomWarningMask } set RoomWarningMask(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.RoomWarningMask = data } get CustomRoomWarning() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.CustomRoomWarning } set CustomRoomWarning(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.CustomRoomWarning = data } get DisableMicAutoMute() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.DisableMicAutoMute } set DisableMicAutoMute(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.DisableMicAutoMute = data } getHardwareSupport() { if (!this.#hardwareSupport) throw this.#cannotAccessBeforeInitError; return this.#hardwareSupport; } async #saveHardwareSupport() { if (!this.#roomId) throw this.#cannotAccessBeforeInitError; await this.#kv.getKv().set([this.#roomId, 'hardware'], this.#hardwareSupport); } async addHardwareSupport(...hardware: RoomDataTypes.HardwareSupport[]) { if (!this.#hardwareSupport || !this.#roomId) throw this.#cannotAccessBeforeInitError; if (typeof hardware == 'string') { this.#hardwareSupport.add(hardware); await this.#saveHardwareSupport(); } else { for (const hw of hardware) this.#hardwareSupport.add(hw); await this.#saveHardwareSupport(); } } async removeHardwareSupport(hardware: RoomDataTypes.HardwareSupport) { if (!this.#hardwareSupport) throw this.#cannotAccessBeforeInitError; this.#hardwareSupport.delete(hardware); await this.#saveHardwareSupport(); } /** * Removes support for every hardware type in the room. When saving immediately after this, no client can legally join the room. * * In production, ensure at least one hardware type is supported. */ removeAllHardwareSupport() { this.#hardwareSupport = new Set(); } /** * Adds support for every hardware type in the room. */ addAllHardwareSupport() { this.#hardwareSupport = new Set(HardwareSupports); } /** * Visits are fetched during every access, not during init */ async getVisitCount() { if (!this.#roomId) throw this.#cannotAccessBeforeInitError; const val = await this.#kv.getKv().get([RoomFactory.roomsKey, this.#roomId, 'visits']); if (val.value == null) return 0; else return val.value; } /** * Visits are fetched during every access, not during init */ async addVisit() { if (!this.#roomId) throw this.#cannotAccessBeforeInitError; const visits = await this.getVisitCount(); await this.#kv.getKv().set([RoomFactory.roomsKey, this.#roomId, 'visits'], visits + 1); } getGalvanicTags(): GalvanicTagDTO[] { const tags: GalvanicTagDTO[] = []; if (this.IsAGRoom) tags.push({ Tag: "recroomoriginal", Type: TagType.AGOnly }); if (this.IsDormRoom) tags.push({ Tag: "dormroom", Type: TagType.AGOnly }); if (this.Name === "Paintball" || this.Name === "PaintballVR") tags.push({ Tag: "paintball", Type: TagType.AGOnly }); const hardwareSupport = this.getHardwareSupport(); if (hardwareSupport.has('screens')) tags.push({ Tag: "screen", Type: TagType.Auto }); if (hardwareSupport.has('walk_vr')) tags.push({ Tag: "walkvr", Type: TagType.Auto }); if (hardwareSupport.has('teleport_vr')) tags.push({ Tag: "teleportvr", Type: TagType.Auto }); if (this.AllowsJuniors) tags.push({ Tag: "junior", Type: TagType.Auto }); return tags; } getTags(): TagDTO[] { if (!this.#tags) return []; return this.#tags.values().toArray().map(val => ({ Tag: val, Type: TagType.General })); } setTags(tags: Set) { this.#tags = tags; } }