roooooms 2

This commit is contained in:
2025-09-06 22:22:36 -04:00
parent 03b751dda6
commit 2aa5352350
15 changed files with 473 additions and 1004 deletions

View File

@@ -45,8 +45,8 @@ export class Instance {
getPlayers() {
return this.#players.values().toArray();
}
playerIsHere(profile: Profile) {
return Boolean(this.getPlayers().find(prof => prof.same(profile)));
hasPlayer(profile: Profile) {
return this.#players.values().some(prof => prof.getId() === profile.getId());
}
removePlayer(profile: Profile) {
this.#players.delete(profile);

View File

@@ -3,19 +3,146 @@ import { ServerContentBase } from "../ContentBase.ts";
import KV from "../persistence/kv.ts";
import type Profile from "../profiles/profile.ts";
import { RoomFactory } from "./internal/RoomFactory.ts";
import { FactoryMode } from "./internal/RoomDataTypes.ts";
import { FactoryMode, HardwareSupports, RoomDataTypes, WriteMode } from "./internal/RoomDataTypes.ts";
import { AGRoom, AGRoomLocation, AGRoomRuntimeConfig } from "./internal/ClientRoomTypes.ts";
import Command from "../commands/command.ts";
import z from "zod";
import { RoomLocation } from "../instances/base.ts";
export class ServerRoomsBase extends ServerContentBase {
#subroomKv = new KV('subrooms', true);
#log = new Logging("Rooms");
static agRoomIdsKey = "agrooms";
static baseRoomIdsKey = "baserooms";
static roomNamesKey = "room_names";
static playerDormsKey = "dorms";
protected override async start() {
#agrooms: Set<number> = new Set();
#baserooms: Set<number> = new Set();
override async start() {
await this.#subroomKv.init();
this.#log.i('[sub]rooms database initialized');
const agrooms = await this.kv.getKv().get<Set<number>>([ServerRoomsBase.agRoomIdsKey]);
if (agrooms.value !== null) this.#agrooms = agrooms.value;
this.#log.i(`${this.#agrooms.size} AG rooms exist`);
const baserooms = await this.kv.getKv().get<Set<number>>([ServerRoomsBase.baseRoomIdsKey]);
if (baserooms.value !== null) this.#baserooms = baserooms.value;
this.server.Commands.addRootCommand(new Command({
key: ["rooms", "r", "room"],
subcommands: [
new Command({
key: ["initag", "initagrooms", "initagroom", "iag"],
zod: z.tuple([]).rest(z.string()),
exec: async (...arrayPath: string[]) => {
const path = arrayPath.join(' ');
try {
const config = JSON.parse((await Deno.readTextFile(path)).toString()) as AGRoomRuntimeConfig;
this.#log.d('Starting AG room initialization');
this.initBuiltinRooms(config.Rooms, config.Locations);
} catch (err) {
return err as Error;
}
},
help: "Initialize AG rooms with AGRoomRuntimeConfig from provided file path"
}),
new Command({
key: ["getplayerdorm", "playerdorm", "pd", "dorm"],
zod: z.tuple([z.coerce.number().min(1).max(Math.pow(2, 31))]),
exec: async (playerId: number) => {
const factory = await this.getPlayerDorm(this.server.Profiles.get(playerId))
},
help: "Get the domroom information for a certain profile/player"
})
],
}))
}
async #writeAgRooms() {
await this.kv.getKv().set([ServerRoomsBase.agRoomIdsKey], this.#agrooms);
}
async #writeBaseRooms() {
await this.kv.getKv().set([ServerRoomsBase.baseRoomIdsKey], this.#baserooms);
}
async initBuiltinRooms(rooms: AGRoom[], locations: AGRoomLocation[]) {
await Promise.all(rooms.map(async room => {
if (room.Accessibility == RoomDataTypes.RoomAccessibility.Private) return;
if ([
"ArtTesting",
"AnimationRecordingStudio",
"Calibration",
"ARRoom",
"Registration",
"DormRoom"
].includes(room.Name)) return;
const roomFactory = await this.write();
if (roomFactory == null) {
this.#log.w(`No factory given while writing builtin room "${room.Name}"!`);
return;
}
const supportsVRLow = room.Scenes.map(scene => locations.find(loc => loc.ReplicationId == scene.RoomSceneLocationId))
.filter(val => typeof val !== 'undefined').some(loc => loc.SupportsVRLow) ?? false;
const supportsMobile = room.Scenes.map(scene => locations.find(loc => loc.ReplicationId == scene.RoomSceneLocationId))
.filter(val => typeof val !== 'undefined').some(loc => loc.SupportsMobile) ?? false;
roomFactory.Name = room.Name;
roomFactory.Description = room.Description;
roomFactory.Accessibility = room.Accessibility;
roomFactory.State = RoomDataTypes.RoomState.Active;
roomFactory.Description = room.Description;
roomFactory.IsAGRoom = true;
roomFactory.CloningAllowed = room.CloningAllowed;
roomFactory.ImageName = `${room.Name}.png`
const supportPromises: Promise<unknown>[] = [];
roomFactory.removeAllHardwareSupport();
if (room.SupportsScreens) supportPromises.push(roomFactory.addHardwareSupport("screens"));
if (room.SupportsWalkVR) supportPromises.push(roomFactory.addHardwareSupport("walk_vr"));
if (room.SupportsTeleportVR) supportPromises.push(roomFactory.addHardwareSupport("teleport_vr"));
if (supportsMobile) supportPromises.push(roomFactory.addHardwareSupport("mobile"));
if (supportsVRLow) supportPromises.push(roomFactory.addHardwareSupport("low_vr"));
await Promise.all(room.Scenes.map(async scene => {
const subroomFactory = await roomFactory.newSubroom();
subroomFactory.addSave("");
subroomFactory.RoomId = roomFactory.getRoomId();
subroomFactory.Name = scene.Name;
subroomFactory.RoomSceneLocationId = scene.RoomSceneLocationId;
subroomFactory.IsSandbox = scene.IsSandbox;
subroomFactory.CanMatchmakeInto = scene.CanMatchmakeInto;
subroomFactory.MaxPlayers = scene.MaxPlayers;
await subroomFactory.write();
roomFactory.addSubroom(subroomFactory.RoomSceneId);
}));
await Promise.all(supportPromises);
this.#agrooms.add(roomFactory.getRoomId());
await roomFactory.write();
if (room.CloningAllowed) this.#baserooms.add(roomFactory.getRoomId());
}));
await this.#writeAgRooms();
await this.#writeBaseRooms();
this.#log.i(`${this.#agrooms.size} AG rooms added: [${this.#agrooms.values().toArray().join(',')}]`);
}
async getAvailableRoomId() {
let id = Math.round(Math.random() * Math.pow(2, 31));
while ((await this.kv.getKv().get<unknown>([RoomFactory.roomsKey, id, 'meta'])).value !== null) id = await this.getAvailableRoomId();
return id;
}
getKv() {
@@ -30,11 +157,48 @@ export class ServerRoomsBase extends ServerContentBase {
async getPlayerDorm(profile: Profile) {
const id = await this.kv.getKv().get<number>([ServerRoomsBase.playerDormsKey, profile.getId()]);
if (id.value == null) return null;
if (id.value == null) {
const roomFactory = await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Write, writeMode: WriteMode.WriteIfFree, id: await this.getAvailableRoomId() });
if (!roomFactory) return null;
roomFactory.setRoomProperties({
Name: `DormRoom`,
Description: "Your private dorm.",
CreatorPlayerId: profile.getId(),
ImageName: "",
State: RoomDataTypes.RoomState.Active,
Accessibility: RoomDataTypes.RoomAccessibility.Private,
SupportsLevelVoting: false,
IsAGRoom: false,
IsDormRoom: true,
CloningAllowed: false,
AllowsJuniors: true,
RoomWarningMask: 0,
CustomRoomWarning: "",
DisableMicAutoMute: null
});
await roomFactory.addHardwareSupport(...HardwareSupports);
roomFactory.setTags(new Set(["dormroom"]));
const subroomFactory = await roomFactory.newSubroom();
subroomFactory.CanMatchmakeInto = true;
subroomFactory.IsSandbox = true;
subroomFactory.MaxPlayers = 4;
subroomFactory.Name = "Home";
subroomFactory.RoomId = roomFactory.getRoomId();
subroomFactory.RoomSceneLocationId = RoomLocation.DormRoom;
subroomFactory.addSave("");
roomFactory.addSubroom(subroomFactory.RoomSceneId);
await subroomFactory.write();
await roomFactory.write();
return roomFactory;
}
else return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Fetch, id: id.value });
}
async getFromName(name: string) {
async getByName(name: string) {
const id = await this.getIdFromName(name);
if (id == null) return null;
return await this.get(id);
@@ -44,4 +208,8 @@ export class ServerRoomsBase extends ServerContentBase {
return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Fetch, id: id });
}
async write(mode?: WriteMode) {
return await new RoomFactory(this.server, this.kv).init({ mode: FactoryMode.Write, writeMode: mode ? mode : WriteMode.WriteIfFree, id: await this.getAvailableRoomId() });
}
}

View File

@@ -55,4 +55,9 @@ export interface AGRoomLocation {
}
}[]
}
}
export interface AGRoomRuntimeConfig {
Locations: AGRoomLocation[],
Rooms: AGRoom[]
}

View File

@@ -8,6 +8,9 @@ export enum FactoryMode {
Write = 'write'
}
/**
* All clients RecRoom can run on.
*/
export const HardwareSupports = ["screens", "walk_vr", "teleport_vr", "low_vr", "mobile"] as const;
export type HardwareSupport = typeof HardwareSupports[number];
@@ -138,8 +141,7 @@ export interface DatabaseRoomContent {
}
export interface DatabaseRoom {
Hardware: Set<HardwareSupport>,
Subrooms: number[],
Tags: Set<number>,
Tags: Set<string>,
CoOwners: Set<number>,
InvitedCoOwners: Set<number>,
Hosts: Set<number>,
@@ -154,14 +156,13 @@ export interface DatabaseSubroom {
IsSandbox: boolean,
MaxPlayers: number,
CanMatchmakeInto: boolean,
LatestSaveId: number,
Saves: RoomSave[]
LatestSaveId: number | null,
}
export type RoomSaveMap = Map<number, RoomSave>
export interface RoomSave {
SaveId: number,
DataBlobName: string,
SavedAt: string
SavedAt: Date
}
export * as RoomDataTypes from "./RoomDataTypes.ts";

View File

@@ -1,17 +1,17 @@
import Logging from "@proxnet/undead-logging";
import type KV from "../../persistence/kv.ts";
import { type ServerBase } from "../../server.ts";
import { DatabaseRoom, FactoryMode, GalvanicTagDTO, RoomDataTypes, TagDTO, TagType, WriteMode } from "./RoomDataTypes.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 = true;
const roomDebug = false;
interface FactoryOptionsBase {
mode: FactoryMode,
id?: number,
id: number,
name?: string
}
export interface WriteFactoryOptions extends FactoryOptionsBase {
@@ -36,6 +36,7 @@ export class RoomFactory {
writeMode: WriteMode = WriteMode.WriteIfFree;
#obj: DatabaseRoom | null = null;
#subrooms: Set<number> | null = null;
#hardwareSupport: Set<RoomDataTypes.HardwareSupport> | null = null;
#tags: Set<string> | null = null;
@@ -49,6 +50,22 @@ export class RoomFactory {
}
/**
* 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");
@@ -63,11 +80,38 @@ export class RoomFactory {
}
const obj = await this.#kv.getKv().get<DatabaseRoom>([RoomFactory.roomsKey, this.#roomId, 'meta']);
if (obj.value == null) return null;
else this.#obj = obj.value;
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<Set<number>>([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<Set<RoomDataTypes.HardwareSupport>>([RoomFactory.roomsKey, this.#roomId, 'hardware']);
if (hardwareSupport.value == null) return null;
if (hardwareSupport.value == null) this.#hardwareSupport = new Set(HardwareSupports);
else this.#hardwareSupport = hardwareSupport.value;
const tags = await this.#kv.getKv().get<Set<string>>([RoomFactory.roomsKey, this.#roomId, 'tags']);
@@ -102,7 +146,7 @@ export class RoomFactory {
const key = [RoomFactory.roomsKey, this.#roomId, 'meta'];
const val = await this.#kv.getKv().get(key);
if (val == null) await w();
if (val.value == null) await w();
else {
if (this.writeMode == WriteMode.Overwrite) await w();
else throw new Error("Room already exists");
@@ -117,7 +161,7 @@ export class RoomFactory {
const galvTags = this.getGalvanicTags();
const subroomExports = (await Promise.all(
this.getSubrooms().map(subroom => this.getSubroom(subroom))
this.getSubrooms().values().map(subroom => this.getSubroom(subroom))
)).map(factory => factory.export());
return {
@@ -154,14 +198,36 @@ export class RoomFactory {
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.#obj) throw this.#cannotAccessBeforeInitError;
return this.#obj.Subrooms;
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 }
@@ -178,8 +244,8 @@ export class RoomFactory {
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 RoomAccessibility() { if (!this.#obj) throw this.#cannotAccessBeforeInitError; else return this.#obj.Room.Accessibility }
set RoomAccessibility(data) { if (!this.#obj) throw this.#cannotAccessBeforeInitError; this.#obj.Room.Accessibility = 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 }
@@ -229,6 +295,20 @@ export class RoomFactory {
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
@@ -252,8 +332,8 @@ export class RoomFactory {
const tags: GalvanicTagDTO[] = [];
if (this.IsAGRoom) tags.push({ Tag: "recroomoriginal", Type: TagType.AGOnly });
if (this.IsDormRoom) tags.push({ Tag: "dormroom", Type: TagType.Auto });
if (this.Name === "Paintball" || this.Name === "PaintballVR") tags.push({ Tag: "paintball", Type: TagType.Auto });
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 });

View File

@@ -1,6 +1,7 @@
import Logging from "@proxnet/undead-logging";
import type KV from "../../persistence/kv.ts";
import { type ServerBase } from "../../server.ts";
import { DatabaseSubroom, FactoryMode, RoomDataTypes, RoomSave, WriteMode } from "./RoomDataTypes.ts";
import { DatabaseSubroom, FactoryMode, RoomDataTypes, RoomSave, RoomSaveMap, WriteMode } from "./RoomDataTypes.ts";
export interface SubroomFactoryOptions {
mode: FactoryMode,
@@ -8,6 +9,8 @@ export interface SubroomFactoryOptions {
id: number
}
const log = new Logging("SubroomFactoryBase");
export class SubroomFactory {
#server: ServerBase;
@@ -19,7 +22,7 @@ export class SubroomFactory {
writeMode: WriteMode = WriteMode.WriteIfFree;
#obj: DatabaseSubroom | null = null;
#saves: RoomSave[] | null = null;
#saves: RoomSaveMap | null = null;
#cannotAccessBeforeInitError = new Error("Cannot access properties before initialization");
#cannotWriteBeforeInitError = new Error("Cannot write before initialization");
@@ -32,13 +35,22 @@ export class SubroomFactory {
}
async init(options: SubroomFactoryOptions) {
const data = await this.#kv.getKv().get<DatabaseSubroom>([options.id, 'meta']);
if (data == null && this.factoryMode == FactoryMode.Fetch) throw new Error("No such subroom");
const saves = await this.#kv.getKv().get<RoomSave[]>([options.id, 'saves']);
this.#saves = saves.value;
this.#obj = data.value;
if (options.mode == FactoryMode.Fetch && data == null) throw new Error("No such subroom");
const saves = await this.#kv.getKv().get<RoomSaveMap>([options.id, 'saves']);
this.#saves = saves.value ?? new Map<number, RoomSave>();
this.#obj = options.mode == FactoryMode.Fetch ? data.value : {
RoomId: 0,
RoomSceneLocationId: "",
Name: "Subroom data init failed, contact an admin!",
IsSandbox: false,
MaxPlayers: 8,
CanMatchmakeInto: true,
LatestSaveId: null
}; // use template object when writing
this.#subroomId = options.id;
return this;
@@ -66,7 +78,7 @@ export class SubroomFactory {
IsSandbox: this.IsSandbox,
MaxPlayers: this.MaxPlayers,
CanMatchmakeInto: this.CanMatchmakeInto,
DataModifiedAt: save.SavedAt,
DataModifiedAt: save.SavedAt.toISOString(),
DataBlobName: save.DataBlobName
}
}
@@ -98,24 +110,41 @@ export class SubroomFactory {
if (!this.#saves) throw this.#cannotAccessBeforeInitError;
let newId = Math.round(Math.random() * Math.pow(2, 31));
while (this.#saves.some(save => save.SaveId == newId)) newId = this.#getAvailableSaveId();
while (this.#saves.has(newId)) newId = this.#getAvailableSaveId();
return newId;
}
addSave(dataBlobName: string) {
/**
* Add a save (history) to the scene. Automatically resets the LatestSaveId.
*
* Use empty string for no datablob
*
* @param dataBlobName Filename of datablob on CDN
*/
addSave(dataBlobName?: string) {
if (!this.#saves) throw this.#cannotAccessBeforeInitError;
this.#saves.push({
SaveId: this.#getAvailableSaveId(),
DataBlobName: dataBlobName,
SavedAt: new Date().toISOString()
const newId = this.#getAvailableSaveId();
this.#saves.set(newId, {
DataBlobName: dataBlobName ?? "",
SavedAt: new Date()
});
this.LatestSaveId = newId;
}
getSaves() {
if (!this.#saves) throw this.#cannotAccessBeforeInitError;
return this.#saves;
}
getLatestSave() {
if (!this.#saves) throw this.#cannotAccessBeforeInitError;
return this.#saves.find(save => save.SaveId == this.LatestSaveId);
if (!this.#saves || !this.#obj) throw this.#cannotAccessBeforeInitError;
else if (!this.#obj.LatestSaveId) throw new Error(`No save is marked as the latest save`);
else {
if (this.#saves.size === 0) {
log.w(`No save could be found when fetching the latest save for subroomid ${this.#subroomId}!`);
return null;
} else if (this.#saves.size === 1) return this.#saves.values().toArray()[0];
else return this.#saves.get(this.#obj.LatestSaveId);
}
}
}