/* 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 Logging from "@proxnet/undead-logging"; import { Profile } from "../profiles.ts"; import { RoomInstance, InstanceOptions } from "./types.ts"; import { Config } from "../../config.ts"; import Presence from "./presence.ts"; import { RoomFactory } from "../content/rooms/RoomFactory.ts"; import { RoomDataTypes } from "../content/rooms/DataTypes.ts"; import { PushNotificationId } from "../../socket/types.ts"; import Server from "../server.ts"; const log = new Logging("Instances"); const config = Config.getConfig(); /** * `Map` */ const instanceSet: Set = new Set(); export class Instance { #players = new Set(); timeCreated = new Date().toISOString(); #id: number; #room: RoomDataTypes.RoomDetails | undefined; #subroom: RoomDataTypes.RoomScene | undefined; #eventId?: number; // not yet implemented #name?: string; #priv?: boolean; #inProgress?: boolean; #blob?: string; constructor(id: number) { this.#id = id; } async init(options: InstanceOptions) { const scene = options.Room.Scenes[options.SceneIndex]; if (!scene) throw new Error("The specified scene does not exist."); let instanceName; if (scene.Name == 'Home' || scene.Name === options.Room.Room.Name) instanceName = `^${options.Room.Room.Name}`; else instanceName = `^${options.Room.Room.Name}.${scene.Name}`; if (options.IsDorm) { const dormCreatorPlayer = Server.UnifiedProfile.get(options.Room.Room.CreatorPlayerId); if (!dormCreatorPlayer) throw new Error("Creator of dorm does not exist."); const player = await dormCreatorPlayer.export(); if (player) instanceName = `@${player.displayName}'s Dorm`; } this.#room = options.Room; this.#subroom = scene; this.#name = instanceName; this.#blob = scene.DataBlobName; this.#inProgress = false; this.#priv = options.Private ? options.Private : false; return this; } equalInstance(instance: RoomInstance) { return instance.roomInstanceId == this.#id; } getAllPlayers() { return this.#players.values().toArray(); } hasPlayer(player: Profile) { return this.getAllPlayers().includes(player); } removePlayer(player: Profile) { if (!this.hasPlayer(player)) throw new Error(`Cannot remove player ${player.getId()} from instance ${this.#id} they are not in`); this.#players.delete(player); player.setInstance(null); } updatePlayers() { for (const player of this.#players.values()) player.getSocketHandler()?.sendNotification(PushNotificationId.SubscriptionUpdateGameSession, this.snapshot()); } async addPlayer(player: Profile) { const currentInstance = player.getInstance(); if (currentInstance && currentInstance.equalInstance(this)) return; if (currentInstance) currentInstance.removePlayer(player); if (!this.isFull) { const instancePlayers = this.getAllPlayers(); const profileExport = await player.export(); log.i(`Player ${player.getId()} "${profileExport?.displayName}" went to '${this.name}' with ${instancePlayers.length} other players`); this.#players.add(player); player.setInstance(this); const pres = await Presence.get(player); pres.update(); const room = await new RoomFactory({ id: this.roomId }).init(); await room?.addVisit(); // move some of this to a dedicated "onPlayerMove" function } else log.w(`Instance ${this.roomInstanceId} is full. Cannot add player ${player.getId()}`); log.d(`Players in instance ${this.#id}: ${this.#players.values().toArray().map(prof => prof.getId()).join(',')}`); } get roomInstanceId() { return this.#id } get roomId() { return this.#room ? this.#room?.Room.RoomId : 0 } get subRoomId() { return this.#subroom ? this.#subroom?.RoomSceneId : 0 } get location() { return this.#subroom ? this.#subroom?.RoomSceneLocationId : "" } get dataBlob() { return this.#blob ? this.#blob : undefined } set dataBlob(data) { this.#blob = data } get eventId() { return this.#eventId } get photonRegionId() { return config.public.photonRegionId } get photonRoomId() { return `GC20200306-${this.#id}` } get name() { return this.#name ? this.#name : "InstanceNameError" } get maxCapacity() { return this.#subroom ? this.#subroom.MaxPlayers : 8 } get isFull() { return this.#players.size >= this.maxCapacity } get isPrivate() { return this.#priv ? this.#priv : false } set isPrivate(data) { this.#priv = data } get isInProgress() { return this.#inProgress ? this.#inProgress : false } set isInProgress(data) { this.#inProgress = data } snapshot() { const inst: RoomInstance = { roomInstanceId: this.roomInstanceId, roomId: this.roomId, subRoomId: this.subRoomId, location: this.location, dataBlob: this.dataBlob, eventId: this.eventId, photonRegionId: this.photonRegionId, photonRoomId: this.photonRoomId, name: this.name, maxCapacity: this.maxCapacity, isFull: this.isFull, isPrivate: this.isPrivate, isInProgress: this.isInProgress } return inst; } destroy() { instanceSet.delete(this); if (this.#players.size !== 0) for (const player of this.#players) player.getSocketHandler()?.sendNotification(PushNotificationId.Logout); } } class InstancesBase { getInstance(id: number) { const instances = instanceSet.values().toArray(); const instance = instances.find(val => val.roomInstanceId == id); if (instance) return instance; else return null; } getAllInstances() { return new Set([...instanceSet.values().toArray()]); } getAllRoomInstances(roomId: number) { return new Set([...this.getAllInstances().values().toArray().filter(val => val.roomId == roomId)]); } clearEmptyInstances(roomId?: number) { const beforeCount = instanceSet.size; for (const instance of instanceSet) { if (roomId && instance.roomId == roomId) continue; const profiles = instance.getAllPlayers(); if (profiles.length === 0) { log.i(`Instance ${instance.roomInstanceId} empty, deleting`); instance.destroy(); } } const afterCount = instanceSet.size; log.d(`Cleared empty instances for roomId ${roomId ? roomId : "*"}.\n Instances before: ${beforeCount}\n Instances after: ${afterCount}`); } #generateUniqueInstanceId() { let newInstanceId = Math.round(Math.random() * Math.pow(2, 31)); const allInstances = this.getAllInstances(); while (Array.from(allInstances.values()).map(val => val.roomInstanceId).includes(newInstanceId)) newInstanceId = Math.round(Math.random() * Math.pow(2, 31)); return newInstanceId; } /** * Create an instance with options. * * If `options.FirstPlayer` is not specified, the created instance will not contain any players and may be removed. */ async createInstance(options: InstanceOptions) { const newId = this.#generateUniqueInstanceId(); const newInstance = await new Instance(newId).init(options); instanceSet.add(newInstance); if (options.FirstPlayer) newInstance.addPlayer(options.FirstPlayer); return newInstance; } } const Instances = new InstancesBase(); export default Instances;