All checks were successful
Galvanic Corrosion Cross-Compile / build (push) Successful in 49s
* Added Storage and room saving (will be moved to events later)
* Moved `UnifiedProfile` to new `Server` object, along with `CDN`
- Will move `Rooms` and others to this later
245 lines
8.5 KiB
TypeScript
245 lines
8.5 KiB
TypeScript
/* Galvanic Corrosion - Rec Room custom server for communities.
|
|
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
|
|
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 <https://www.gnu.org/licenses/>. */
|
|
|
|
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<roomId (number), Instance>`
|
|
*/
|
|
const instanceSet: Set<Instance> = new Set();
|
|
|
|
export class Instance {
|
|
|
|
#players = new Set<Profile>();
|
|
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; |