uhh
All checks were successful
Galvanic Corrosion Cross-Compile / build (push) Successful in 57s

The Rewrite™️

- Discord bot removed, will return *eventually*
- Watchdog kills the server with a knife when it does not shut down in (default) 60 seconds
- New event system that works.. better.. and callbacks have types
- Removed a metric ton of circular dependencies that previously would not let the server start up
    * This included splitting up some classes
- Other. internal stuff. I forgot.
This commit is contained in:
2025-06-29 00:16:07 -04:00
parent 0ac46fcee2
commit 746c2203e5
90 changed files with 2408 additions and 1397 deletions

7
.madgerc Normal file
View File

@@ -0,0 +1,7 @@
{
"detectiveOptions": {
"ts": {
"skipTypeImports": true
}
}
}

View File

@@ -3,7 +3,7 @@
[<-- Click to return to README.md](./README.md)
We recommend that you store the configuration file `config.json` in a safe place where Galvanic Corrosion can access it (the current directory).<br>
No other user on your server system should be able to access the file.
No other user on your host system should be able to access the configuration.
## Redis
Redis is database software and must be installed for Galvanic Corrosion.
@@ -19,26 +19,30 @@ If you are unsure of what this does, leave it unchanged.
## Network
Galvanic Corrosion listens on two ports:
* 13370/tcp(http) - for web endpoints
* 13371/tcp(http+ws) - for websockets
### Some issues may appear when connecting directly to a GC server's listening address.
Sockets behave erratically when connected directly to clients. This is a suspected issue with Deno websockets.<br>
For now, it is recommended that you use a middleman/proxy with your server. (see below)
Galvanic Corrosion listens on two ports by default:
* 13370/tcp (http)
* 13371/tcp (http+ws)
Currently, HTTPS and WSS are unsupported *directly* on GC. You can use a compatible reverse proxy solution to secure your server.<br>
[Cloudflare Tunnels](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) (requires a domain with Cloudflare) are recommended.
Port-forward or expose your server in some way. HTTPS is **strongly** recommended for your public address.
Once your server is reachable, the nameserver (and similar functions) need to know what the public "official" address of your server is.<br>
For example, your server listens on 10.0.0.6:13370(+13371) but is tunneled to my-gc-server(-socket).coolguy.xyz:
Once your server is reachable locally, the nameserver (and similar functions) need to know what the public "official" address of your server is.<br>
For example, your server listens on 10.0.0.6:13370 and 10.0.0.6:13371, but is tunneled to my-gc-server.coolguy.xyz and my-gc-server-socket.coolguy.xyz:
- Set the "public host" for `web` and `socket` in `config.json` to the "official" address of your server
* In the example, my-gc-server.coolguy.xyz and my-gc-server-socket.coolguy.xyz
* This includes port numbers, but not the protocol
- If your public address uses HTTPS (it should for proper authentication), enable `securepublichost`
You can test your configuration by navigating to `https://your-server.coolguy.xyz/ns`.<br>
You can test your configuration by navigating to `https://my-gc-server.coolguy.xyz/ns`. (use your server host)<br>
Each field should contain your server's public address with an optional path at the end.
## Public Configuration
## Public
This section contains basic information regarding your server.
`serverName`: Somewhat invisible to players, but is an official label your server could appear as (to future server lists?)
@@ -63,8 +67,12 @@ this can be anything *except* for "none" or 4, since there is only one server to
`initialRoom`: On game startup, redirects the player to this room name instead of their DormRoom. Set to null if a "natural" startup is preferred.<br>
This room must not be private and must be matchmakeable.
## General
`watchdogTimeout`: Terminate the server process after this number of milliseconds when SIGINT is emitted.<br>
This can help when your server does not shut down gracefully.
## Logging
These three values expose booleans you can change to enable/disable logging various messages used for debugging or troubleshooting purposes.
These three booleans enable/disable logging various messages used for debugging or troubleshooting purposes.
## Discord
Can be `null`. Currently unused.
@@ -77,6 +85,8 @@ Parameters used by the server's authentication mechanisms.
`secret`: Used to generate tokens. Should never be shared (the entire file) and can be a string of characters containing no words or patterns.
<br>Use secure cryptography APIs in programming languages to generate random strings.
`console`: Key used to connect to the server console. Must be different than your `auth.secret`.
`timeout`: The maximum age for a token.
`steamkey`: When not `null`, checks the Steam authentication ticket given by the client with the Steam User Auth API. Recommended for public servers.

View File

@@ -7,17 +7,17 @@
"cross-compile": "deno run prebuild && deno run compile-win-a && deno run compile-linux-a && deno run postbuild",
"dev": "deno run -A src/main.ts --dev",
"prebuild": "deno run -A ./prebuild.ts",
"postbuild": "deno run -A ./postbuild.ts"
"postbuild": "deno run -A ./postbuild.ts",
"depcheck": "deno run -A npm:madge --circular --extensions ts ./src"
},
"imports": {
"@gz/jwt": "jsr:@gz/jwt@^0.1.0",
"@proxnet/undead-logging": "jsr:@proxnet/undead-logging@^1.2.0",
"@proxnet/undead-logging": "jsr:@proxnet/undead-logging@^1.3.0",
"@types/cookie-parser": "npm:@types/cookie-parser@^1.4.8",
"@types/express": "npm:@types/express@^5.0.0",
"@types/multer": "npm:@types/multer@^1.4.12",
"@types/validator": "npm:@types/validator@^13.12.2",
"cookie-parser": "npm:cookie-parser@^1.4.7",
"discord.js": "npm:discord.js@^14.16.3",
"express": "npm:express@^4.21.2",
"ioredis": "npm:ioredis@^5.5.0",
"multer": "npm:multer@^1.4.5-lts.2",
@@ -31,5 +31,5 @@
"./src/types/http.ts"
]
},
"version": "0.1.0"
"version": "0.2.0"
}

1150
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,8 +16,8 @@ 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/>. */
try {
Deno.removeSync('./ver.ts');
Deno.renameSync('./ver.ts.bak', 'ver.ts');
await Deno.remove('./ver.ts');
await Deno.rename('./ver.ts.bak', 'ver.ts');
} catch (err) {
console.error(`Cannot post-build version information: ${err}`);
Deno.exit(1);

View File

@@ -28,9 +28,9 @@ try {
const newVerString = `${file.version}-${new TextDecoder().decode(commitHash.stdout).trim()}`;
if (file.version) {
Deno.writeTextFileSync('./ver.ts.bak', devVer);
Deno.writeTextFileSync('./ver.ts', devVer.replace('development', newVerString));
console.info('Built version information');
await Deno.writeTextFile('./ver.ts.bak', devVer);
await Deno.writeTextFile('./ver.ts', devVer.replace('development', newVerString));
console.info(`Built version information: Commit ${newVerString}`);
}
} catch (err) {
console.error(`Cannot build version information: ${err}`);

View File

@@ -19,12 +19,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import express from "express";
import Logging from "@proxnet/undead-logging";
import { decode } from "@gz/jwt";
import { Config } from "./config.ts";
import { AuthType, User, UserTokenFormat } from "./data/users.ts";
import { ProfileTokenFormat } from "./data/profiles.ts";
import { Config } from "./config/config.ts";
import { User } from "./data/users.ts";
import { AuthType } from "./data/UserTypes.ts";
import z from "zod";
import Matchmaking from "./data/live/base.ts";
import Server from "./data/server.ts";
import Server from "./data/server/server.ts";
import { TokenSchema } from "./data/auth/TokenSchema.ts";
import type { TokenFormat } from "./data/auth/TokenBaseFormat.ts";
const config = Config.getConfig();
@@ -48,19 +50,6 @@ export function setCacheAllowed(_rq: express.Request, rs: express.Response, nxt:
nxt();
}
export function generateRandomString(length: number) {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let randomString = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
randomString += characters.charAt(randomIndex);
}
return randomString;
}
export function checkQueryTypes<T>(typeDef: T) {
return (
rq: express.Request,
@@ -251,31 +240,6 @@ export class RateLimiter {
}
}
export interface TokenBaseFormat {
typ: AuthType;
iss: string;
exp: number;
}
export type TokenFormat = UserTokenFormat | ProfileTokenFormat;
const TokenBaseSchema = z.object({
typ: z.nativeEnum(AuthType),
iss: z.string().url(),
exp: z.number()
});
export const UserTokenSchema = TokenBaseSchema.extend({
sub: z.string(),
typ: z.literal(AuthType.Web)
});
export const ProfileTokenSchema = TokenBaseSchema.extend({
sub: z.number(),
typ: z.literal(AuthType.Game)
});
export const TokenSchema = z.discriminatedUnion('typ', [
UserTokenSchema,
ProfileTokenSchema
]);
export async function Authentication(
rq: express.Request,
rs: express.Response,
@@ -375,4 +339,12 @@ export function stopTimer(_rq: express.Request, rs: express.Response) {
log.n(`(${rs.locals.reqId}) Middleware took ${(performance.now() - rs.locals.timer).toString().substring(0, 6)} ms`);
}
export function requestDebug(rq: express.Request, _rs: express.Response, nxt: express.NextFunction) {
log.d(`URL: ${rq.originalUrl}`);
log.d(`From IP: ${getSrcIpDefault(rq)}`);
log.d(`Headers: ${Object.keys(rq.headers).map(val => `\n ${val}: ${rq.headers[val]}`)}`);
nxt();
}
export * as APIUtils from "./apiutils.ts";

View File

@@ -0,0 +1,73 @@
/* 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 type { PhotonRegionCodeNumber, PhotonRegionCodeString } from "../data/live/PhotonTypes.ts";
type RedisConfiguration = {
host: string;
port: number;
username: string;
password: string;
db: number;
};
type WebConfiguration = {
port: number;
host: string;
publichost: string;
securepublichost: boolean;
};
type WebRootConfiguration = {
api: WebConfiguration;
socket: WebConfiguration;
};
type PublicConfiguration = {
serverName: string;
serverId: string;
owner: string;
motd: string;
levelScale: number;
maxLevels: number;
patches: string[];
photonRegionId: PhotonRegionCodeNumber | PhotonRegionCodeString;
initialRoom: string | null;
};
type LoggingConfiguration = {
notfound: boolean;
debug: boolean;
network: boolean;
};
type AuthConfiguration = {
secret: string;
/**
* In Hours
*/
timeout: number;
steamkey: string | null;
};
type GeneralConfiguration = {
/** In milliseconds */
watchdogTimeout: number;
}
export type GalvanicConfiguration = {
redis: RedisConfiguration;
web: WebRootConfiguration;
public: PublicConfiguration;
general: GeneralConfiguration;
logging: LoggingConfiguration;
auth: AuthConfiguration;
};

View File

@@ -17,72 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import Logging from "@proxnet/undead-logging";
import * as fs from "node:fs";
import { PhotonRegionCodeNumber, PhotonRegionCodeString } from "./data/live/types.ts";
import { GalvanicConfiguration } from "./GalvanicConfiguration.ts";
import { PhotonRegionCodeNumber } from "../data/live/PhotonTypes.ts";
const log = new Logging("Config");
type RedisConfiguration = {
host: string;
port: number;
username: string;
password: string;
db: number;
};
type WebConfiguration = {
port: number;
host: string;
publichost: string;
securepublichost: boolean;
}
type WebRootConfiguration = {
api: WebConfiguration,
socket: WebConfiguration
};
type PublicConfiguration = {
serverName: string;
serverId: string;
owner: string;
motd: string;
levelScale: number;
maxLevels: number;
patches: string[];
photonRegionId: PhotonRegionCodeString | PhotonRegionCodeNumber;
initialRoom: string | null;
};
type LoggingConfiguration = {
notfound: boolean;
debug: boolean;
network: boolean;
};
type DiscordConfiguration = {
token: string;
clientId: string;
guildId: string;
};
type AuthConfiguration = {
secret: string;
/**
* In Hours
*/
timeout: number;
steamkey: string | null;
};
export type GalvanicConfiguration = {
redis: RedisConfiguration;
web: WebRootConfiguration;
public: PublicConfiguration;
logging: LoggingConfiguration;
discord: DiscordConfiguration | null;
auth: AuthConfiguration;
};
export const defaultConfig: GalvanicConfiguration = {
redis: {
host: "127.0.0.1",
@@ -116,12 +55,14 @@ export const defaultConfig: GalvanicConfiguration = {
photonRegionId: PhotonRegionCodeNumber.us,
initialRoom: null
},
general: {
watchdogTimeout: 60000
},
logging: {
notfound: false,
debug: false,
network: false,
},
discord: null,
auth: {
secret: "CHANGE-ME-PLEASE",
timeout: 3,
@@ -140,12 +81,12 @@ try {
}
/** Does the configuration file exist on the disk? */
export function configurationExists() {
function configurationExists() {
return fs.existsSync("./config.json");
}
/** Place [or overwrite] the [existing] default configuration in the current directory */
export function generateDefaultConfig() {
function generateDefaultConfig() {
fs.writeFileSync(
"./config.json",
JSON.stringify(defaultConfig, undefined, " "),

View File

@@ -15,8 +15,12 @@ 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/>. */
export class ProfileEventsManager {
export type UserInitOptions = {
client_id: string;
pubkey: string;
};
export enum AuthType {
Game,
Web
}

View File

@@ -0,0 +1,43 @@
/* 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 { AuthType } from "../UserTypes.ts";
export type TokenFormat = UserTokenFormat | ProfileTokenFormat;
export interface TokenBaseFormat {
typ: AuthType;
iss: string;
exp: number;
}
export interface TokenBaseFormat {
typ: AuthType;
iss: string;
exp: number;
}
export interface UserTokenFormat extends TokenBaseFormat {
sub: string;
typ: AuthType.Web;
}
export interface ProfileTokenFormat extends TokenBaseFormat {
sub: number;
role: "developer" | "user";
typ: AuthType.Game;
}

View File

@@ -0,0 +1,37 @@
/* 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 z from "zod";
import { AuthType } from "../UserTypes.ts";
const TokenBaseSchema = z.object({
typ: z.nativeEnum(AuthType),
iss: z.string().url(),
exp: z.number()
});
export const UserTokenSchema = TokenBaseSchema.extend({
sub: z.string(),
typ: z.literal(AuthType.Web)
});
export const ProfileTokenSchema = TokenBaseSchema.extend({
sub: z.number(),
typ: z.literal(AuthType.Game)
});
export const TokenSchema = z.discriminatedUnion('typ', [
UserTokenSchema,
ProfileTokenSchema
]);

View File

@@ -15,51 +15,26 @@ 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";
type Callback<T> = (event: T) => void;
const log = new Logging("BaseEvent");
export class EventManager<Events extends { [K in keyof Events]: unknown }> {
#listeners: {
[K in keyof Events]?: Set<Callback<Events[K]>>
} = {};
export interface Event {
time: Date
}
export class EventManager {
private eventCallbacks: Map<string, Set<(ev: unknown) => void>> = new Map();
private getSubSet(event: string): Set<(ev: unknown) => void> {
let subset = this.eventCallbacks.get(event);
if (!subset) {
subset = new Set();
this.eventCallbacks.set(event, subset);
}
return subset;
on<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
if (!this.#listeners[eventName])
this.#listeners[eventName] = new Set();
this.#listeners[eventName]!.add(callback);
}
on<T extends Event>(event: string, cb: (ev: T) => void) {
const typeSafeCallback = ((ev: unknown) => {
cb(ev as T);
});
this.getSubSet(event).add(typeSafeCallback);
return typeSafeCallback;
off<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
this.#listeners[eventName]?.delete(callback);
if (this.#listeners[eventName]?.size === 0)
delete this.#listeners[eventName];
}
off(event: string, cb: (ev: unknown) => void) {
const subset = this.getSubSet(event);
subset.delete(cb);
}
emit<T extends Event>(event: string, ev: T) {
const subset = this.getSubSet(event);
for (const cb of subset.values()) {
try {
cb(ev);
} catch (err) {
if (err instanceof Error) log.e(`Error when executing callback: ${err.stack}`);
else log.e(`Error when executing callback: ${err}`);
}
}
emit<K extends keyof Events>(eventName: K, payload: Events[K]): void {
this.#listeners[eventName]?.forEach((callback) => callback(payload));
}
}

View File

@@ -1,164 +0,0 @@
/* 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 { Config } from "../config.ts";
import { Redis } from "../db.ts";
import { Objectives, ObjectiveType } from "./objectives.ts";
export type LevelProgressionItem = {
Level: number;
RequiredXp: number;
};
export type AutoMicMutingConfig = {
MicSpamVolumeThreshold: number,
MicVolumeSampleInterval: number,
MicVolumeSampleRollingWindowLength: number,
MicSpamSamplePercentageForWarning: number,
MicSpamSamplePercentageForWarningToEnd: number,
MicSpamSamplePercentageForForceMute: number,
MicSpamSamplePercentageForForceMuteToEnd: number,
MicSpamWarningStateVolumeMultiplier: number
}
export type PublicConfig = {
ShareBaseUrl: string
ServerMaintenance: {
StartsInMinutes: number;
};
LevelProgressionMaps: LevelProgressionItem[];
DailyObjectives: Objectives.Objective[][];
AutoMicMutingConfig: AutoMicMutingConfig,
};
/**
* Plain public config, NOT GameConfigs
*/
export function getConfig() {
const c = Config.getConfig();
if (typeof c == "undefined") return null;
const config = c as Config.GalvanicConfiguration;
function generateLevelProgressionMap() {
const m: LevelProgressionItem[] = [];
for (let i = 0; i < config.public.maxLevels + 1; i++) {
m.push({
Level: i,
RequiredXp: Math.round(i * config.public.levelScale * 20),
});
}
return m;
}
const conf: PublicConfig = {
ServerMaintenance: {
StartsInMinutes: 0,
},
LevelProgressionMaps: generateLevelProgressionMap(),
DailyObjectives: [
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
],
[
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0},
{type: ObjectiveType.Default, score: 0}
]
],
AutoMicMutingConfig: {
MicSpamVolumeThreshold: 1.125,
MicVolumeSampleInterval: 0.25,
MicVolumeSampleRollingWindowLength: 7.0,
MicSpamSamplePercentageForWarning: 0.8,
MicSpamSamplePercentageForWarningToEnd: 0.2,
MicSpamSamplePercentageForForceMute: 0.8,
MicSpamSamplePercentageForForceMuteToEnd: 0.2,
MicSpamWarningStateVolumeMultiplier: 0.25
},
ShareBaseUrl: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}/{0}` // {0} is replaced by the game
};
return conf;
}
export async function getAllGameConfigs() {
try {
const gameConfigs = new Map<string, string>();
const val = await Redis.Database.hgetall(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
);
for (const key of Object.keys(val)) {
gameConfigs.set(key, val[key]);
}
return gameConfigs;
} catch (error) {
console.error("Error fetching game configs:", error);
throw error;
}
}
export function setGameConfig(key: string, value: string) {
return Redis.Database.hset(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
key,
value,
);
}
export function getGameConfig(key: string) {
return Redis.Database.hget(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
key,
);
}
export * as GameConfigs from "./config.ts";

View File

@@ -0,0 +1,61 @@
/* 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 { Redis } from "../../db.ts";
export async function getAllGameConfigs() {
try {
const gameConfigs = new Map<string, string>();
const val = await Redis.Database.hgetall(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
);
for (const key of Object.keys(val)) {
gameConfigs.set(key, val[key]);
}
return gameConfigs;
} catch (error) {
console.error("Error fetching game configs:", error);
throw error;
}
}
export function setGameConfig(key: string, value: string) {
return Redis.Database.hset(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
key,
value,
);
}
export function getGameConfig(key: string) {
return Redis.Database.hget(
Redis.buildKey(
Redis.KeyGroups.Config.Root,
Redis.KeyGroups.Config.Game,
),
key,
);
}
export * as GameConfigs from "./GameConfigs.ts";

View File

@@ -0,0 +1,95 @@
/* 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 { Config } from "../../config/config.ts";
import type { GalvanicConfiguration } from "../../config/GalvanicConfiguration.ts";
import { ObjectiveType } from "../content/ObjectiveTypes.ts";
import { PublicConfig, LevelProgressionItem } from "./PublicConfigTypes.ts";
export function getPublicConfig() {
const c = Config.getConfig();
if (typeof c == "undefined") return null;
const config = c as GalvanicConfiguration;
function generateLevelProgressionMap() {
const m: LevelProgressionItem[] = [];
for (let i = 0; i < config.public.maxLevels + 1; i++) {
m.push({
Level: i,
RequiredXp: Math.round(i * config.public.levelScale * 20),
});
}
return m;
}
const conf: PublicConfig = {
ServerMaintenance: {
StartsInMinutes: 0,
},
LevelProgressionMaps: generateLevelProgressionMap(),
DailyObjectives: [
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
],
[
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 },
{ type: ObjectiveType.Default, score: 0 }
]
],
AutoMicMutingConfig: {
MicSpamVolumeThreshold: 1.125,
MicVolumeSampleInterval: 0.25,
MicVolumeSampleRollingWindowLength: 7.0,
MicSpamSamplePercentageForWarning: 0.8,
MicSpamSamplePercentageForWarningToEnd: 0.2,
MicSpamSamplePercentageForForceMute: 0.8,
MicSpamSamplePercentageForForceMuteToEnd: 0.2,
MicSpamWarningStateVolumeMultiplier: 0.25
},
ShareBaseUrl: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}/{0}` // {0} is replaced by the game
};
return conf;
}

View File

@@ -0,0 +1,43 @@
/* 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 type { Objective } from "../content/ObjectiveTypes.ts";
export interface LevelProgressionItem {
Level: number;
RequiredXp: number;
};
interface AutoMicMutingConfig {
MicSpamVolumeThreshold: number;
MicVolumeSampleInterval: number;
MicVolumeSampleRollingWindowLength: number;
MicSpamSamplePercentageForWarning: number;
MicSpamSamplePercentageForWarningToEnd: number;
MicSpamSamplePercentageForForceMute: number;
MicSpamSamplePercentageForForceMuteToEnd: number;
MicSpamWarningStateVolumeMultiplier: number;
};
export type PublicConfig = {
ShareBaseUrl: string;
ServerMaintenance: {
StartsInMinutes: number;
};
LevelProgressionMaps: LevelProgressionItem[];
DailyObjectives: Objective[][];
AutoMicMutingConfig: AutoMicMutingConfig;
};

View File

@@ -108,6 +108,4 @@ export enum ObjectiveType {
export type Objective = {
type: ObjectiveType;
score: number;
};
export * as Objectives from "./objectives.ts";
}

View File

@@ -16,10 +16,10 @@ 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 { generateRandomString } from "../../apiutils.ts";
import { Profile } from "../profiles.ts";
import { generateRandomString } from "../../utils.ts";
import type { Profile } from "../profile/base/profiles.ts";
import * as fs from "node:fs";
import Server from "../server.ts";
import Server from "../server/server.ts";
const log = new Logging("CDN");
@@ -68,7 +68,6 @@ export class CDNBase {
pathParts.pop();
const dirPath = pathParts.join('/');
log.d(dirPath);
if (dirPath) await Deno.mkdir(dirPath, { recursive: true });
}
@@ -113,6 +112,7 @@ export class CDNBase {
const metaData = await Deno.readTextFile(`${path}.gcmeta`);
const parsedMeta = JSON.parse(metaData);
const meta: MetaFile = {
creationPlayer: Server.UnifiedProfile.get(parsedMeta.creationPlayer) || undefined,
dateCreated: new Date(parsedMeta.dateCreated),

View File

@@ -15,14 +15,13 @@ 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 { EventManager } from "./baseevent.ts";
import { CDNBase } from "./content/cdn.ts";
import { UnifiedProfileBase } from "./profiles.ts";
import { RoomFactory } from "./RoomFactory.ts";
import { SubroomFactory } from "./SubroomFactory.ts";
class ServerBase extends EventManager {
CDN = new CDNBase();
UnifiedProfile = new UnifiedProfileBase();
export interface SubroomUpdatedEvent {
subroom: SubroomFactory
}
const Server = new ServerBase();
export default Server;
export interface RoomUpdatedEvent {
room: RoomFactory
}

View File

@@ -15,11 +15,16 @@ 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 { Redis } from "../../../db.ts";
import Rooms from "../rooms.ts";
import { FactoryMode, HardwareSupport, HardwareSupportStrings, RoomAccessibility, RoomDataTypes, RoomDetails, RoomState, WriteMode } from "./DataTypes.ts";
import Server from "../../server/server.ts";
import { RoomDataTypes } from "./base/DataTypes.ts";
import { SubroomFactory } from "./SubroomFactory.ts";
export const roomdebug = false;
const log = new Logging("RoomFactory");
interface RoomFactoryOptions {
id?: number;
name?: string;
@@ -29,6 +34,14 @@ interface RoomFactoryOptions {
export class RoomFactory {
static 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;
}
static Keys = {
Meta: "roommeta",
Subrooms: "subrooms",
@@ -68,7 +81,7 @@ export class RoomFactory {
async init() {
if (this.factoryMode !== FactoryMode.Fetch) {
if (this.factoryMode !== RoomDataTypes.FactoryMode.Fetch) {
if (!this.#specifiedId) throw this.#mustSpecifyIdInWriteModeError;
this.#resolvedId = this.#specifiedId;
return this;
@@ -76,7 +89,7 @@ export class RoomFactory {
if (!this.#specifiedId) {
if (!this.#specifiedName) throw this.#mustSpecifyEitherIdOrNameError;
const id = await Rooms.getIdFromName(this.#specifiedName);
const id = await RoomFactory.getIdFromName(this.#specifiedName);
if (!id) return null;
this.#specifiedId = id;
}
@@ -89,6 +102,8 @@ export class RoomFactory {
RoomFactory.Keys.Meta
));
if (roomdebug) log.d(`Init success, specifiedId: ${this.#specifiedId}`);
return this;
}
@@ -119,6 +134,8 @@ export class RoomFactory {
}
if (this.Name !== 'DormRoom') await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Room_Names, this.Name), this.RoomId);
Server.emit('room.updated', { room: this });
}
async export() {
@@ -128,7 +145,7 @@ export class RoomFactory {
const subroomPromises = subroomIds.map(id => this.getSubroom(id).init());
const subrooms = (await Promise.all(subroomPromises)).map(subroom => subroom.export());
const details: RoomDetails = {
const details: RoomDataTypes.RoomDetails = {
Room: {
RoomId: this.RoomId,
Name: this.Name,
@@ -160,13 +177,13 @@ export class RoomFactory {
Tags: []
}
if (roomdebug) log.d(`Exported details for room ${this.RoomId}`);
return details;
}
getSubroom(id: number, factoryMode?: FactoryMode, writeMode?: WriteMode) {
getSubroom(id: number, factoryMode?: RoomDataTypes.FactoryMode, writeMode?: RoomDataTypes.WriteMode) {
if (!this.#resolvedId) throw this.#unresolvedError;
return new SubroomFactory({
roomId: this.#resolvedId,
subroomId: id,
factoryMode: factoryMode ? factoryMode : undefined,
writeMode : writeMode ? writeMode : undefined
@@ -240,11 +257,11 @@ export class RoomFactory {
set ImageName(data) { this.#setHashValue(this.#imageKey, data) }
#stateKey = 'State';
get State(): RoomState { return this.#fetchNumberKey(this.#stateKey, RoomState.Active) }
get State(): RoomDataTypes.RoomState { return this.#fetchNumberKey(this.#stateKey, RoomDataTypes.RoomState.Active) }
set State(data) { this.#setHashValue(this.#stateKey, data) }
#accessKey = 'RoomAccessibility';
get RoomAccessibility(): RoomAccessibility { return this.#fetchNumberKey(this.#accessKey, RoomAccessibility.Unlisted) }
get RoomAccessibility(): RoomDataTypes.RoomAccessibility { return this.#fetchNumberKey(this.#accessKey, RoomDataTypes.RoomAccessibility.Unlisted) }
set RoomAccessibility(data) { this.#setHashValue(this.#accessKey, data) }
#votingKey = 'SupportsLevelVoting';
@@ -279,20 +296,20 @@ export class RoomFactory {
get DisableMicAutoMute() { return this.#fetchBooleanKey(this.#muteKey, false) }
set DisableMicAutoMute(data) { this.#setHashValue(this.#muteKey, data) }
async getHardwareSupport(): Promise<HardwareSupport[]> {
async getHardwareSupport(): Promise<RoomDataTypes.HardwareSupport[]> {
if (!this.#resolvedId) throw this.#unresolvedError;
return (await Redis.Database.smembers(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#resolvedId.toString(),
RoomFactory.Keys.HardwareSupport
))) as HardwareSupport[];
))) as RoomDataTypes.HardwareSupport[];
}
async addHardwareSupport(hardware: HardwareSupport | HardwareSupport[] | '*') {
async addHardwareSupport(hardware: RoomDataTypes.HardwareSupport | RoomDataTypes.HardwareSupport[] | '*') {
if (!this.#resolvedId) throw this.#unresolvedError;
if (hardware === '*') {
await Promise.all(HardwareSupportStrings.map(str => this.addHardwareSupport(str as HardwareSupport) ));
await Promise.all(RoomDataTypes.HardwareSupportStrings.map(str => this.addHardwareSupport(str as RoomDataTypes.HardwareSupport) ));
return;
}
@@ -314,7 +331,7 @@ export class RoomFactory {
}
}
async removeHardwareSupport(hardware: HardwareSupport) {
async removeHardwareSupport(hardware: RoomDataTypes.HardwareSupport) {
if (!this.#resolvedId) throw this.#unresolvedError;
await Redis.Database.srem(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,

View File

@@ -15,21 +15,27 @@ 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 { Redis } from "../../db.ts";
import { Profile } from "../profiles.ts";
import { Redis } from "../../../db.ts";
import { Profile } from "../../profile/base/profiles.ts";
import Logging from "@proxnet/undead-logging";
import { BuiltinRoom, FactoryMode, IntegratedRoomScene, RoomAccessibility, RoomDataTypes, RoomDetails, RoomState, WriteMode } from "./rooms/DataTypes.ts";
import { RoomFactory } from "./rooms/RoomFactory.ts";
import { SubroomFactory } from "./rooms/SubroomFactory.ts";
import { RootPath } from "../../path.ts";
import { Instance } from "../live/instances.ts";
import { PushNotificationId } from "../../socket/types.ts";
import { SubroomFactory } from "./SubroomFactory.ts";
import { RootPath } from "../../../path.ts";
import { RoomFactory } from "./RoomFactory.ts";
import { RoomDataTypes } from "../rooms/base/DataTypes.ts";
import Rooms from "./base/RoomsBase.ts";
const log = new Logging("Rooms");
const rooms = JSON.parse(Deno.readTextFileSync(`${RootPath}/res/rooms.json`)) as BuiltinRoom[];
const builtinRooms = JSON.parse(Deno.readTextFileSync(`${RootPath}/res/rooms.json`)) as RoomDataTypes.BuiltinRoom[];
class RoomsBase {
const baseImageChanges = [
{ room: "DodgeballVR", image: "Dodgeball" },
{ room: "PaintballVR", image: "Paintball" },
{ room: "StuntRunnerBaseRoom", image: "StuntRunner" },
{ room: "BowlingAlley", image: "Bowling" },
];
class RoomsMiscBase {
static Keys = {
BuiltinGenerated: "builtinrooms-done",
@@ -37,33 +43,13 @@ class RoomsBase {
}
getAllBuiltinRooms() {
return rooms;
}
async get(id: number) {
try {
const factory = await new RoomFactory({ id: id }).init();
if (!factory) return null;
return factory.export();
} catch {
return null;
}
}
async getByName(name: string) {
try {
const factory = await new RoomFactory({ name: name }).init();
if (!factory) return null;
return factory.export();
} catch {
return null;
}
return builtinRooms;
}
async getAllBuiltinRoomGenerations() {
const ids = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsBase.Keys.AGRooms));
const ids = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsMiscBase.Keys.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);
return (await Promise.all(parsedIds.map(id => Rooms.get(id)))).filter(val => val !== null);
}
async #getAvailableRoomId() {
@@ -77,9 +63,7 @@ class RoomsBase {
let id = Math.round(Math.random() * Math.pow(2, 31));
while ((await Redis.Database.exists(
Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
roomid.toString(),
RoomFactory.Keys.Subrooms,
Redis.KeyGroups.Subrooms.Root,
id.toString(),
SubroomFactory.Keys.Meta
))) >= 1)
@@ -93,20 +77,20 @@ class RoomsBase {
result: RoomDataTypes.CreateModifyRoomStatus;
}
const factory = await new RoomFactory({ id: roomid, factoryMode: FactoryMode.Fetch }).init();
const factory = await new RoomFactory({ id: roomid, factoryMode: RoomDataTypes.FactoryMode.Fetch }).init();
if (!factory || !factory.CloningAllowed) return { result: RoomDataTypes.CreateModifyRoomStatus.PermissionDenied } as RoomClone;
if (factory.Name == 'DormRoom') return { result: RoomDataTypes.CreateModifyRoomStatus.ReservedName } as RoomClone;
if (factory.Name == newname) return { result: RoomDataTypes.CreateModifyRoomStatus.DuplicateName } as RoomClone;
const newFactory = await new RoomFactory({ id: await Rooms.#getAvailableRoomId(), factoryMode: FactoryMode.Write }).init();
const newFactory = await new RoomFactory({ id: await RoomsMisc.#getAvailableRoomId(), factoryMode: RoomDataTypes.FactoryMode.Write }).init();
if (!newFactory) return { result: RoomDataTypes.CreateModifyRoomStatus.Unknown } as RoomClone;
newFactory.CreatorPlayerId = newowner.getId();
newFactory.Description = factory.Description;
newFactory.Name = newname;
newFactory.ImageName = factory.ImageName;
newFactory.State = RoomState.Active;
newFactory.RoomAccessibility = RoomAccessibility.Private;
newFactory.State = RoomDataTypes.RoomState.Active;
newFactory.RoomAccessibility = RoomDataTypes.RoomDataTypes.RoomAccessibility.Private;
newFactory.SupportsLevelVoting = factory.SupportsLevelVoting;
newFactory.IsAGRoom = false;
newFactory.IsDormRoom = factory.IsDormRoom;
@@ -122,9 +106,10 @@ class RoomsBase {
const oldSubroomIds = await factory.getAllSubroomIds();
const promises = oldSubroomIds.map(async (id) => {
const newSubroomFactory = await newFactory.getSubroom(id, FactoryMode.Write, WriteMode.Overwrite).init();
const oldSubroomFactory = await factory.getSubroom(id, FactoryMode.Fetch).init();
const newSubroomFactory = await newFactory.getSubroom(id, RoomDataTypes.RoomDataTypes.FactoryMode.Write, RoomDataTypes.RoomDataTypes.WriteMode.Overwrite).init();
const oldSubroomFactory = await factory.getSubroom(id, RoomDataTypes.RoomDataTypes.FactoryMode.Fetch).init();
newSubroomFactory.RoomId = newFactory.RoomId;
newSubroomFactory.RoomSceneLocationId = oldSubroomFactory.RoomSceneLocationId;
newSubroomFactory.Name = oldSubroomFactory.Name;
newSubroomFactory.IsSandbox = oldSubroomFactory.IsSandbox;
@@ -137,7 +122,7 @@ class RoomsBase {
await Promise.all(promises);
await newFactory.write();
newFactory.factoryMode = FactoryMode.Fetch;
newFactory.factoryMode = RoomDataTypes.RoomDataTypes.FactoryMode.Fetch;
return {
factory: newFactory,
@@ -176,15 +161,15 @@ class RoomsBase {
async generateNewDorm(player: Profile) {
const id = await this.#getAvailableRoomId();
const factory = await new RoomFactory({ id: id, factoryMode: FactoryMode.Write, writeMode: WriteMode.WriteIfFree }).init();
const factory = await new RoomFactory({ id: id, factoryMode: RoomDataTypes.FactoryMode.Write, writeMode: RoomDataTypes.WriteMode.WriteIfFree }).init();
if (!factory) return null;
factory.Name = "DormRoom";
factory.Description = "Your private room.";
factory.CreatorPlayerId = player.getId();
factory.ImageName = "DefaultProfileImage.png";
factory.State = RoomState.Active;
factory.RoomAccessibility = RoomAccessibility.Private;
factory.State = RoomDataTypes.RoomState.Active;
factory.RoomAccessibility = RoomDataTypes.RoomAccessibility.Private;
factory.SupportsLevelVoting = false;
factory.IsAGRoom = false;
factory.IsDormRoom = true;
@@ -195,10 +180,11 @@ class RoomsBase {
factory.addHardwareSupport('*');
const subroomFactory = await factory.getSubroom(await this.#getAvailableSubRoomId(id), FactoryMode.Write, WriteMode.WriteIfFree).init();
const subroomFactory = await factory.getSubroom(await this.#getAvailableSubRoomId(id), RoomDataTypes.FactoryMode.Write, RoomDataTypes.WriteMode.WriteIfFree).init();
if (!subroomFactory) return null;
subroomFactory.RoomSceneLocationId = IntegratedRoomScene.DormRoom;
subroomFactory.RoomId = id;
subroomFactory.RoomSceneLocationId = RoomDataTypes.IntegratedRoomScene.DormRoom;
subroomFactory.Name = "Home";
subroomFactory.IsSandbox = true;
subroomFactory.DataBlobName = "";
@@ -212,26 +198,20 @@ class RoomsBase {
}
async generateBuiltinRooms() {
if ((await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsMiscBase.Keys.BuiltinGenerated))) !== null) return true;
if ((await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsBase.Keys.BuiltinGenerated))) !== null) return true;
await Promise.all(rooms.map(async builtinRoom => {
await Promise.all(builtinRooms.map(async builtinRoom => {
if (builtinRoom.Name == 'DormRoom') return;
const newId = await this.#getAvailableRoomId();
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsBase.Keys.AGRooms), newId);
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsMiscBase.Keys.AGRooms), newId);
const factory = await new RoomFactory({ id: newId, factoryMode: FactoryMode.Write, writeMode: WriteMode.Overwrite }).init();
const factory = await new RoomFactory({ id: newId, factoryMode: RoomDataTypes.FactoryMode.Write, writeMode: RoomDataTypes.WriteMode.Overwrite }).init();
if (!factory) return;
factory.Name = builtinRoom.Name;
factory.Description = builtinRoom.Description;
factory.CreatorPlayerId = 1;
const baseImageChanges = [
{ room: "DodgeballVR", image: "Dodgeball" },
{ room: "PaintballVR", image: "Paintball" },
{ room: "StuntRunnerBaseRoom", image: "StuntRunner" },
{ room: "BowlingAlley", image: "Bowling" },
]
if (baseImageChanges.find(change => change.room == builtinRoom.Name)) {
const image = baseImageChanges.find(change => change.room == builtinRoom.Name)!;
@@ -239,7 +219,7 @@ class RoomsBase {
}
else factory.ImageName = `${builtinRoom.Name}.png`;
factory.State = RoomState.Active;
factory.State = RoomDataTypes.RoomState.Active;
factory.RoomAccessibility = builtinRoom.Accessibility;
factory.SupportsLevelVoting = builtinRoom.SupportsLevelVoting;
factory.IsAGRoom = true;
@@ -254,9 +234,10 @@ class RoomsBase {
await Promise.all(builtinRoom.Scenes.map(async subroom => {
const newSubroomId = await this.#getAvailableSubRoomId(newId);
const subroomFactory = await factory.getSubroom(newSubroomId, FactoryMode.Write, WriteMode.Overwrite).init();
const subroomFactory = await factory.getSubroom(newSubroomId, RoomDataTypes.FactoryMode.Write, RoomDataTypes.WriteMode.Overwrite).init();
if (!subroomFactory) return;
subroomFactory.RoomId = newId;
subroomFactory.RoomSceneLocationId = subroom.RoomSceneLocationId;
subroomFactory.Name = subroom.Name;
subroomFactory.IsSandbox = subroom.IsSandbox;
@@ -269,53 +250,13 @@ class RoomsBase {
await factory.write();
}));
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsBase.Keys.BuiltinGenerated), "1");
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Rooms.Root, RoomsMiscBase.Keys.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 getSubroomIdsFromRoom(id: number): Promise<string[]>;
async getSubroomIdsFromRoom(id: number, stringify: false): Promise<number[]>;
async getSubroomIdsFromRoom(id: number, stringify: boolean | undefined = false): Promise<string[] | number[]> {
const ids = await Redis.Database.smembers(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
id.toString(),
RoomFactory.Keys.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;
}
async socketUpdateRoom(instance: Instance) {
const room = await this.get(instance.roomId);
if (!room) return;
for (const player of instance.getAllPlayers()) {
const sock = player.getSocketHandler();
if (!sock) continue;
sock.sendNotification("RoomInstanceUpdate", instance.snapshot());
sock.sendNotification(PushNotificationId.SubscriptionUpdateRoom, room);
}
}
}
const Rooms = new RoomsBase();
const RoomsMisc = new RoomsMiscBase();
export { rooms as BuiltinRooms };
export default Rooms;
export { builtinRooms as BuiltinRooms };
export default RoomsMisc;

View File

@@ -15,15 +15,18 @@ 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 { Redis } from "../../../db.ts";
import { RoomDataTypes, IntegratedRoomScene, RoomScene, WriteMode, FactoryMode } from "./DataTypes.ts";
import { RoomFactory } from "./RoomFactory.ts";
import Server from "../../server/server.ts";
import { RoomDataTypes } from "./base/DataTypes.ts";
import { RoomFactory, roomdebug } from "./RoomFactory.ts";
const log = new Logging("SubroomFactory");
interface SubroomFactoryOptions {
roomId: number;
subroomId: number;
writeMode?: WriteMode;
factoryMode?: FactoryMode;
writeMode?: RoomDataTypes.WriteMode;
factoryMode?: RoomDataTypes.FactoryMode;
}
export class SubroomFactory {
@@ -42,13 +45,11 @@ export class SubroomFactory {
#writeMode: RoomDataTypes.WriteMode = RoomDataTypes.WriteMode.WriteIfFree;
factoryMode: RoomDataTypes.FactoryMode = RoomDataTypes.FactoryMode.Fetch;
#roomId: number;
#subroomId: number;
#hash: Record<string, string> | null = null;
constructor(options: SubroomFactoryOptions) {
this.#roomId = options.roomId;
this.#subroomId = options.subroomId;
if (options.writeMode) this.#writeMode = options.writeMode;
if (options.factoryMode) this.factoryMode = options.factoryMode;
@@ -56,12 +57,10 @@ export class SubroomFactory {
async init() {
if (!this.#roomId || !this.#subroomId) throw this.#unspecifiedArguments;
if (!this.#subroomId) throw this.#unspecifiedArguments;
this.#hash = await Redis.Database.hgetall(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#roomId.toString(),
RoomFactory.Keys.Subrooms,
Redis.KeyGroups.Subrooms.Root,
this.#subroomId.toString(),
SubroomFactory.Keys.Meta
));
@@ -76,9 +75,7 @@ export class SubroomFactory {
else {
const dbkey = Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#roomId.toString(),
RoomFactory.Keys.Subrooms,
Redis.KeyGroups.Subrooms.Root,
this.#subroomId.toString(),
SubroomFactory.Keys.Meta
);
@@ -89,21 +86,25 @@ export class SubroomFactory {
}
if (!this.#hash) throw this.#hashValuesNotSetError;
this.#hash['DataModifiedAt'] = new Date().toISOString();
this.#hash[this.#modifiedKey] = new Date().toISOString();
await Redis.Database.hset(dbkey, this.#hash);
}
await Redis.Database.sadd(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#roomId.toString(),
RoomFactory.Keys.Subrooms
), this.RoomSceneId);
if (this.#hash[this.#roomIdKey])
await Redis.Database.sadd(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#hash[this.#roomIdKey].toString(),
RoomFactory.Keys.Subrooms
), this.RoomSceneId);
if (roomdebug) log.d(`Writing subroom ${this.RoomSceneId}: ${JSON.stringify(this.#hash)}`);
Server.emit('room.subroom.updated', { subroom: this });
}
export(): RoomScene {
export(): RoomDataTypes.RoomScene {
if (roomdebug) log.d(`Exported subroom details for room:subroom ${this.RoomId}:${this.RoomSceneId}`);
return {
RoomSceneId: this.RoomSceneId,
RoomId: this.RoomId,
@@ -158,10 +159,17 @@ export class SubroomFactory {
get RoomSceneId() { return this.#subroomId }
get RoomId() { return this.#roomId }
#roomIdKey = 'RoomId';
get RoomId() { return this.#fetchNumberKey(this.#roomIdKey, 0) }
set RoomId(data) { this.#setHashValue(this.#roomIdKey, data) }
#locationKey = 'RoomSceneLocationId';
get RoomSceneLocationId(): IntegratedRoomScene { return this.#fetchStringKey(this.#locationKey, IntegratedRoomScene.PerformanceHall) as IntegratedRoomScene }
get RoomSceneLocationId(): RoomDataTypes.IntegratedRoomScene {
return this.#fetchStringKey(
this.#locationKey,
RoomDataTypes.IntegratedRoomScene.PerformanceHall
) as RoomDataTypes.IntegratedRoomScene;
}
set RoomSceneLocationId(data) { this.#setHashValue(this.#locationKey, data) }
#nameKey = 'Name';
@@ -189,9 +197,7 @@ export class SubroomFactory {
async addBlobHistory(date: Date, filename: string) {
await Redis.Database.hset(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#roomId.toString(),
RoomFactory.Keys.Subrooms,
Redis.KeyGroups.Subrooms.Root,
this.#subroomId.toString(),
SubroomFactory.Keys.Blobs
), date.toISOString(), filename);
@@ -204,9 +210,7 @@ export class SubroomFactory {
}
const hist = await Redis.Database.hgetall(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
this.#roomId.toString(),
RoomFactory.Keys.Subrooms,
Redis.KeyGroups.Subrooms.Root,
this.#subroomId.toString(),
SubroomFactory.Keys.Blobs
));

View File

@@ -0,0 +1,68 @@
/* 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 { Redis } from "../../../../db.ts";
import { RoomDetails } from "./DataTypes.ts";
import { RoomFactory } from "../RoomFactory.ts";
export class RoomsBase {
async get(id: number) {
try {
const factory = await new RoomFactory({ id: id }).init();
if (!factory) return null;
return factory.export();
} catch {
return null;
}
}
async getByName(name: string) {
try {
const factory = await new RoomFactory({ name: name }).init();
if (!factory) return null;
return factory.export();
} catch {
return null;
}
}
async getSubroomIdsFromRoom(id: number): Promise<string[]>;
async getSubroomIdsFromRoom(id: number, stringify: false): Promise<number[]>;
async getSubroomIdsFromRoom(id: number, stringify: boolean | undefined = false): Promise<string[] | number[]> {
const ids = await Redis.Database.smembers(Redis.buildKey(
Redis.KeyGroups.Rooms.Root,
id.toString(),
RoomFactory.Keys.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 default Rooms;

188
src/data/live/Instance.ts Normal file
View File

@@ -0,0 +1,188 @@
/* 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 { Config } from "../../config/config.ts";
import { PushNotificationId } from "../../socket/types.ts";
import { RoomDataTypes } from "../content/rooms/base/DataTypes.ts";
import { RoomFactory } from "../content/rooms/RoomFactory.ts";
import type { Profile } from "../profile/base/profiles.ts";
import Server from "../server/server.ts";
import Instances from "./instances.ts";
import Presence from "./presence.ts";
import type { InstanceOptions, RoomInstance } from "./types.ts";
import Logging from "@proxnet/undead-logging";
const config = Config.getConfig();
const log = new Logging("InstanceBase");
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);
}
/**
* The client has a push notification for game session updates, but the client
* is based on instances, not game sessions. Possibly simply just an alias for
* 'RoomInstanceUpdate' but was never (or not yet) renamed. It is unknown
* whether the game uses this or not, but it's better to send it just in case.
*/
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() {
Instances.getAllInstances(true).delete(this);
if (this.#players.size !== 0) for (const player of this.#players) player.getSocketHandler()?.sendNotification(PushNotificationId.Logout);
}
updateSubroom(subroom: RoomDataTypes.RoomScene) {
this.#subroom = subroom;
}
}

View File

@@ -0,0 +1,48 @@
/* 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/>. */
export enum PhotonRegionCodeString {
Europe = "eu",
UnitedStates = "us",
Asia = "asia",
Japan = "jp",
Australia = "au",
UnitedStates_West = "usw",
SouthAmerica = "sa",
CanadaEast = "cae",
SouthKorea = "kr",
India = "@in",
Russia = "ru",
RussiaEast = "rue",
None = "none"
}
export enum PhotonRegionCodeNumber {
eu,
us,
asia,
jp,
au = 5,
usw,
sa,
cae,
kr,
"@in",
ru,
rue,
none = 4
}

View File

@@ -15,12 +15,16 @@ 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 Rooms from "../content/rooms.ts";
import { RoomDataTypes } from "../content/rooms/DataTypes.ts";
import { Profile } from "../profiles.ts";
import Logging from "@proxnet/undead-logging";
import RoomsMisc from "../content/rooms/Rooms.ts";
import { RoomDataTypes } from "../content/rooms/base/DataTypes.ts";
import Rooms from "../content/rooms/base/RoomsBase.ts";
import { Profile } from "../profile/base/profiles.ts";
import Instances from "./instances.ts";
import { MatchmakingErrorCode, RoomInstance } from "./types.ts";
const log = new Logging("MatchmakingBase");
const loginLocks: Map<number, string> = new Map();
interface MatchmakingOptions {
@@ -49,6 +53,9 @@ class MatchmakingBase {
else return null;
}
/**
* @deprecated This will be removed as login locks will be saved to the database and cannot then be changed
*/
deleteLoginLock(prof: Profile) {
loginLocks.delete(prof.getId());
}
@@ -75,7 +82,7 @@ class MatchmakingBase {
} else {
// check to make sure room exists, is not private, and is active
const targetRoom = options.roomName !== 'DormRoom' ? await Rooms.getByName(options.roomName) : await Rooms.getProfileDormDefault(options.profile);
const targetRoom = options.roomName !== 'DormRoom' ? await Rooms.getByName(options.roomName) : await RoomsMisc.getProfileDormDefault(options.profile);
if (!targetRoom) return { errorCode: MatchmakingErrorCode.NoSuchRoom };
if (targetRoom.Room.Accessibility == RoomDataTypes.RoomAccessibility.Private && targetRoom.Room.CreatorPlayerId !== options.profile.getId())
return { errorCode: MatchmakingErrorCode.RoomIsPrivate };
@@ -90,7 +97,7 @@ class MatchmakingBase {
if (subroomId) allInstances = allInstances.filter(instance => instance.subRoomId == subroomId);
// filter instances that do not support join in progress and are in progress
const builtinRooms = Rooms.getAllBuiltinRooms();
const builtinRooms = RoomsMisc.getAllBuiltinRooms();
const joinInProgressSubrooms = builtinRooms.map(room =>
({Name: room.Name, Scenes: room.Scenes.map(scene =>
({Name: scene.Name, Supported: scene.SupportsJoinInProgress})
@@ -117,9 +124,11 @@ class MatchmakingBase {
if (!foundInstance) {
const matchmakeableSubrooms = targetRoom.Scenes.filter(scene => scene.CanMatchmakeInto);
const index = Math.floor(Math.random() * matchmakeableSubrooms.length);
log.d(`Scene index ${index} (${targetRoom.Scenes[index] ? targetRoom.Scenes[index].RoomSceneId : "unknown"}) was chosen for matchmaking into ${targetRoom.Room.RoomId}`);
const newInstance = await Instances.createInstance({
Room: targetRoom,
SceneIndex: Math.floor(Math.random() * matchmakeableSubrooms.length),
SceneIndex: index,
FirstPlayer: options.profile,
Private: options.private,
IsDorm: options.roomName == 'DormRoom'

View File

@@ -16,171 +16,30 @@ 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";
import { InstanceOptions } from "./types.ts";
import Server from "../server/server.ts";
import { Instance } from "./Instance.ts";
const log = new Logging("Instances");
const config = Config.getConfig();
/**
* `Map<roomId (number), Instance>`
*/
const instanceSet: Set<Instance> = new Set();
export class Instance {
Server.on('room.subroom.updated', (ev) => {
if (instanceSet.values().map(inst => inst.subRoomId).toArray().includes(ev.subroom.RoomSceneId)) {
const instance = instanceSet.values().toArray().find(inst => inst.subRoomId == ev.subroom.RoomSceneId);
if (!instance) return;
#players = new Set<Profile>();
timeCreated = new Date().toISOString();
instance.dataBlob = ev.subroom.DataBlobName;
instance.updateSubroom(ev.subroom.export());
for (const profile of instance.getAllPlayers())
profile.getSocketHandler()?.sendNotification('RoomInstanceUpdate', instance.snapshot());
#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;
instance.updatePlayers(); // legacy
}
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 {
@@ -191,8 +50,9 @@ class InstancesBase {
else return null;
}
getAllInstances() {
return new Set([...instanceSet.values().toArray()]);
getAllInstances(asSet: boolean | undefined = false) {
if (!asSet) return new Set([...instanceSet.values().toArray()]);
else return instanceSet;
}
getAllRoomInstances(roomId: number) {

View File

@@ -17,10 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { SettingKey } from "../content/settings.ts";
import { Profile } from "../profiles.ts";
import type { Profile } from "../profile/base/profiles.ts";
import { DeviceClass, PlayerStatusVisibility, RoomInstance, VRMovementMode } from "./types.ts";
import Logging from "@proxnet/undead-logging";
import { Instance } from "./instances.ts";
import { Instance } from "./Instance.ts";
import { RoomUpdatedEvent } from "../content/rooms/RoomEvents.ts";
import Server from "../server/server.ts";
import { PushNotificationId } from "../../socket/types.ts";
const log = new Logging("Presence");
@@ -57,6 +60,7 @@ class PlayerPresence {
this.updateOffline();
}, 80000);
Server.on('room.updated', this.#roomUpdatedEventCallback);
}
offline: boolean;
@@ -69,6 +73,20 @@ class PlayerPresence {
lastSeen: Date;
#roomUpdatedEventCallback = (ev: RoomUpdatedEvent) => {
//log.d(`Room ${ev.room.RoomId} updated, notifying client.`);
ev.room.export().then(roomDetails => {
log.d(`${this.roomInstance?.roomId} == ${ev.room.RoomId}, P:${this.playerId}`);
if (this.roomInstance?.roomId == ev.room.RoomId) {
const socket = this.getProfile().getSocketHandler();
if (!socket) return;
socket.sendNotification(PushNotificationId.SubscriptionUpdateRoom, roomDetails);
socket.presenceUpdate();
}
});
}
async updateStatusVisibility() {
const PlayerStatusVisibilityEnum = z.nativeEnum(PlayerStatusVisibility);
@@ -122,6 +140,16 @@ class PlayerPresence {
this.lastSeen = new Date();
}
getProfile() {
return this.#profile;
}
destroy() {
presence.delete(this);
clearInterval(this.intervalId);
Server.off('room.updated', this.#roomUpdatedEventCallback);
}
}
const presence: Set<PlayerPresence> = new Set();
@@ -164,10 +192,8 @@ class PresenceBase {
deleteDeadPresences() {
for (const pres of presence.values())
if (Math.round(new Date().getTime() / 1000) - Math.round(pres.lastSeen.getTime() / 1000) >= 60) {
presence.delete(pres);
clearInterval(pres.intervalId);
}
if (Math.round(new Date().getTime() / 1000) - Math.round(pres.lastSeen.getTime() / 1000) >= 60)
pres.destroy();
}
}

View File

@@ -15,40 +15,9 @@ 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 { RoomDataTypes } from "../content/rooms/DataTypes.ts";
import { Profile } from "../profiles.ts";
export enum PhotonRegionCodeString {
Europe = "eu",
UnitedStates = "us",
Asia = "asia",
Japan = "jp",
Australia = "au",
UnitedStates_West = "usw",
SouthAmerica = "sa",
CanadaEast = "cae",
SouthKorea = "kr",
India = "@in",
Russia = "ru",
RussiaEast = "rue",
None = "none"
}
export enum PhotonRegionCodeNumber {
eu,
us,
asia,
jp,
au = 5,
usw,
sa,
cae,
kr,
"@in",
ru,
rue,
none = 4
}
import { RoomDataTypes } from "../content/rooms/base/DataTypes.ts";
import type { Profile } from "../profile/base/profiles.ts";
import { PhotonRegionCodeString, PhotonRegionCodeNumber } from "./PhotonTypes.ts";
export interface RoomInstance {

View File

@@ -0,0 +1,44 @@
/* 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 { Config } from "../../config/config.ts";
import { Redis } from "../../db.ts";
import type { ProfileTokenFormat } from "../auth/TokenBaseFormat.ts";
import { AuthType } from "../UserTypes.ts";
import { ProfileContentManager } from "./base/profilemanagerbase.ts";
import * as JsonWebToken from "@gz/jwt";
const config = Config.getConfig();
export class ProfileAuth extends ProfileContentManager {
async getToken() {
const payload: ProfileTokenFormat = {
iss: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`,
sub: this.profile.getId(),
role: (await this.getIsOperator()) ? 'developer' : 'user',
exp: Math.round(Date.now() / 1000) + Math.round(config.auth.timeout * 60 * 60),
typ: AuthType.Game,
};
return await JsonWebToken.encode(payload, config.auth.secret, { algorithm: "HS512" });
}
async getIsOperator() {
return (await Redis.Database.sismember(Redis.buildKey(Redis.KeyGroups.Operators), this.profile.getId().toString())) >= 1;
}
}

View File

@@ -29,7 +29,7 @@ export class ProfileAvatarManager extends ProfileContentManager {
#rootKey = Redis.buildKey(
Redis.KeyGroups.Profiles.Root,
this.profileId.toString(),
this.profile.getId().toString(),
Redis.KeyGroups.Profiles.Avatar.Root
);

View File

@@ -15,16 +15,18 @@ 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 type { Profile } from "./profiles.ts";
export class ProfileContentManager {
constructor(profileId: number) {
this.profileId = profileId;
constructor(profile: Profile) {
this.profile = profile;
}
onProfileInit() {
return;
}
profileId: number;
profile: Profile;
}

View File

@@ -15,28 +15,23 @@ 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 { Redis } from "../db.ts";
import { Redis } from "../../../db.ts";
import Logging from "@proxnet/undead-logging";
import Dictionary from "./usernames.ts";
import { Config } from "../config.ts";
import { AuthType } from "./users.ts";
import * as JsonWebToken from "@gz/jwt";
import { TokenBaseFormat } from "../apiutils.ts";
import { DeviceClass, VRMovementMode } from "./live/types.ts";
import { SettingKey } from "./content/settings.ts";
import Dictionary from "../../usernames.ts";
import { DeviceClass, VRMovementMode } from "../../live/types.ts";
import { SettingKey } from "../../content/settings.ts";
import { z } from "zod";
import { SignalRSocketHandler } from "../socket/socket.ts";
import { ProfileSettingsManager } from "./profile/settings.ts";
import { ProfileProgressionManager } from "./profile/progression.ts";
import { ProfileReputationManager } from "./profile/reputation.ts";
import { ProfileRelationshipManager } from "./profile/relationships.ts";
import { ProfileAvatarManager } from "./profile/avatar.ts";
import { EventManager } from "./baseevent.ts";
import { ProfileEvents, ProfileUpdatedEvent } from "./profileevents.ts";
import { Instance } from "./live/instances.ts";
import { ProfileRoomsManager } from "./profile/rooms.ts";
const config = Config.getConfig();
import { SignalRSocketHandler } from "../../../socket/socket.ts";
import { ProfileSettingsManager } from "../settings.ts";
import { ProfileProgressionManager } from "../progression.ts";
import { ProfileReputationManager } from "../reputation.ts";
import { ProfileRelationshipManager } from "../relationships.ts";
import { ProfileAvatarManager } from "../avatar.ts";
import type { ProfileUpdatedEvent } from "../../profileevents.ts";
import { Instance } from "../../live/Instance.ts";
import { ProfileRoomsManager } from "../rooms.ts";
import { type ServerBase } from "../../server/server.ts";
import { ProfileAuth as ProfileAuthManager } from "../ProfileAuth.ts";
const log = new Logging("Profiles");
@@ -63,15 +58,10 @@ export interface SelfAccountExport extends AccountExport {
juniorState?: number,
parentAccountId?: number
}
export interface ProfileTokenFormat extends TokenBaseFormat {
sub: number;
role: "developer" | "user";
typ: AuthType.Game;
}
const reservedIds = [1, 2];
class Profile extends EventManager {
class Profile {
static async exists(id: number) {
return (await Redis.Database.exists(
Redis.buildKey(
@@ -202,26 +192,25 @@ class Profile extends EventManager {
Relationships: ProfileRelationshipManager;
Avatar: ProfileAvatarManager;
Rooms: ProfileRoomsManager;
Auth: ProfileAuthManager;
constructor(id: number) {
super();
this.#id = id;
this.Settings = new ProfileSettingsManager(this.#id);
this.Progression = new ProfileProgressionManager(this.#id);
this.Reputation = new ProfileReputationManager(this.#id);
this.Relationships = new ProfileRelationshipManager(this.#id);
this.Avatar = new ProfileAvatarManager(this.#id);
this.Rooms = new ProfileRoomsManager(this.#id);
this.Settings = new ProfileSettingsManager(this);
this.Progression = new ProfileProgressionManager(this);
this.Reputation = new ProfileReputationManager(this);
this.Relationships = new ProfileRelationshipManager(this);
this.Avatar = new ProfileAvatarManager(this);
this.Rooms = new ProfileRoomsManager(this);
this.Auth = new ProfileAuthManager(this);
}
#emitProfileUpdated() {
const ev: ProfileUpdatedEvent = {
time: new Date(),
profile: this
}
this.emit(ProfileEvents.BaseUpdated, ev);
if (Server) Server.emit('profile.updated', ev);
}
setInstance(instance: Instance | null) {
@@ -236,10 +225,6 @@ class Profile extends EventManager {
return this.#id;
}
async getIsOperator() {
return (await Redis.Database.sismember(Redis.buildKey(Redis.KeyGroups.Operators), this.#id.toString())) >= 1;
}
async getBio() {
const bio = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Bio));
if (!bio) return "";
@@ -331,25 +316,19 @@ class Profile extends EventManager {
return this.#socket;
}
// get, set instance
// this.#instance: RoomInstance
async getToken() {
const payload: ProfileTokenFormat = {
iss: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`,
sub: this.#id,
role: (await this.getIsOperator()) ? 'developer' : 'user',
exp: Math.round(Date.now() / 1000) + Math.round(config.auth.timeout * 60 * 60),
typ: AuthType.Game,
};
return await JsonWebToken.encode(payload, config.auth.secret, { algorithm: "HS512" });
}
}
const profiles: Map<number, Profile> = new Map()
const profiles: Map<number, Profile> = new Map();
// surely this can be fixed
let Server: ServerBase | undefined;
export class UnifiedProfileBase {
constructor(server: ServerBase) {
Server = server;
}
get(id: number) {
let profile = profiles.get(id);
if (!profile) {
@@ -357,7 +336,8 @@ export class UnifiedProfileBase {
const inst = new Profile(id);
profiles.set(id, inst);
profile = inst;
} catch {
} catch (err) {
log.e(`Could not fetch profile: ${(err as Error).stack}`);
return null;
}
}
@@ -365,7 +345,10 @@ export class UnifiedProfileBase {
}
async create(options: ProfileInitOptions) {
return await Profile.init(options);
const profile = await Profile.init(options);
if (!profile) return null;
profiles.set(profile.getId(), profile);
return profile;
}
async exists(id: number) {

View File

@@ -16,13 +16,13 @@ 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 { GameConfigs } from "../config.ts";
import { ProfileContentManager } from "./base/profilemanagerbase.ts";
import { Redis } from "../../db.ts";
import { getPublicConfig } from "../config/PublicConfig.ts";
const log = new Logging("ProfileProgression");
const config = GameConfigs.getConfig();
const config = getPublicConfig();
interface PlayerProgressionExport {
PlayerId: number,
@@ -34,7 +34,7 @@ export class ProfileProgressionManager extends ProfileContentManager {
async export() {
const ex: PlayerProgressionExport = {
PlayerId: this.profileId,
PlayerId: this.profile.getId(),
Level: await this.getLevel(),
XP: await this.getXp()
}
@@ -47,7 +47,7 @@ export class ProfileProgressionManager extends ProfileContentManager {
* @returns The new # of XP
*/
async setXp(xp: number) {
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Xp), xp.toString());
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Xp), xp.toString());
return xp;
}
@@ -85,12 +85,12 @@ export class ProfileProgressionManager extends ProfileContentManager {
}
async getXp() {
let data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Xp));
let data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Xp));
if (data == null) data = (await this.setXp(0)).toString();
const parsedData = parseInt(data);
if (isNaN(parsedData)) {
log.w(`Parsed xp data for ${this.profileId} is NaN!`);
log.w(`Parsed xp data for ${this.profile.getId()} is NaN!`);
const one = config?.LevelProgressionMaps[1];
if (typeof one == 'undefined' && !one) return 0; // fallback since progression data is required
else return one.RequiredXp;

View File

@@ -16,9 +16,9 @@ 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 { Redis } from "../../db.ts";
import { Profile } from "../profiles.ts";
import type { Profile } from "./base/profiles.ts";
import { ProfileContentManager } from "./base/profilemanagerbase.ts";
import Server from "../server.ts";
//import Server from "../server.ts"; // see circular dep comment at bottom
enum RelationshipType {
None,
@@ -64,7 +64,7 @@ export class ProfileRelationshipManager extends ProfileContentManager {
#rootKey = Redis.buildKey(
Redis.KeyGroups.Profiles.Root,
this.profileId.toString(),
this.profile.getId().toString(),
Redis.KeyGroups.Profiles.Relationships.Root
);
@@ -144,8 +144,8 @@ export class ProfileRelationshipManager extends ProfileContentManager {
async #clearAssociationWithRemote(remoteProfileId: number) {
const remoteRootKey = this.#createRemoteRootKey(remoteProfileId);
await Redis.Database.srem(this.#incomingFriends, remoteProfileId);
await Redis.Database.srem(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.OutgoingFriendRequests), this.profileId);
await Redis.Database.srem(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profileId);
await Redis.Database.srem(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.OutgoingFriendRequests), this.profile.getId());
await Redis.Database.srem(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profile.getId());
await Redis.Database.srem(this.#outgoingFriends, remoteProfileId);
}
@@ -158,7 +158,7 @@ export class ProfileRelationshipManager extends ProfileContentManager {
const remoteRootKey = this.#createRemoteRootKey(remoteProfileId);
await Redis.Database.sadd(this.#friendsKey, remoteProfileId);
await Redis.Database.sadd(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.Friends), this.profileId);
await Redis.Database.sadd(Redis.buildKey(remoteRootKey, Redis.KeyGroups.Profiles.Relationships.Friends), this.profile.getId());
}
async denyRequest(remoteProfileId: number) {
@@ -182,7 +182,7 @@ export class ProfileRelationshipManager extends ProfileContentManager {
const remoteKey = this.#createRemoteRootKey(remoteProfileId);
const localMuted = (await this.getAllMuted()).includes(remoteProfileId);
const remoteMuted = await Redis.Database.sismember(Redis.buildKey(remoteKey, Redis.KeyGroups.Profiles.Relationships.Muted), this.profileId);
const remoteMuted = await Redis.Database.sismember(Redis.buildKey(remoteKey, Redis.KeyGroups.Profiles.Relationships.Muted), this.profile.getId());
if (localMuted && remoteMuted) return ReciprocalStatus.Mutual;
else if (localMuted) return ReciprocalStatus.Local;
@@ -194,7 +194,7 @@ export class ProfileRelationshipManager extends ProfileContentManager {
const remoteKey = this.#createRemoteRootKey(remoteProfileId);
const localIgnored = (await this.getAllMuted()).includes(remoteProfileId);
const remoteIgnored = await Redis.Database.sismember(Redis.buildKey(remoteKey, Redis.KeyGroups.Profiles.Relationships.Ignoring), this.profileId);
const remoteIgnored = await Redis.Database.sismember(Redis.buildKey(remoteKey, Redis.KeyGroups.Profiles.Relationships.Ignoring), this.profile.getId());
if (localIgnored && remoteIgnored) return ReciprocalStatus.Mutual;
else if (localIgnored) return ReciprocalStatus.Local;
@@ -213,22 +213,23 @@ export class ProfileRelationshipManager extends ProfileContentManager {
}
async sendPlayerFriendRequest(player: Profile) {
await Redis.Database.sadd(Redis.buildKey(this.#createRemoteRootKey(player.getId()), Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profileId);
await Redis.Database.sadd(Redis.buildKey(this.#createRemoteRootKey(player.getId()), Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profile.getId());
await Redis.Database.sadd(this.#outgoingFriends, player.getId());
}
async removePlayerFriendRequest(player: Profile) {
await Redis.Database.srem(Redis.buildKey(this.#createRemoteRootKey(player.getId()), Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profileId);
await Redis.Database.srem(Redis.buildKey(this.#createRemoteRootKey(player.getId()), Redis.KeyGroups.Profiles.Relationships.IncomingFriendRequests), this.profile.getId());
await Redis.Database.srem(this.#outgoingFriends, player.getId());
}
async ignoreAllAssociatedPlatformUsers(platformid: string) {
const ids = (await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.PlatformAssociations, platformid))).map(val => parseInt(val)).filter(val => !isNaN(val));
async ignoreAllAssociatedPlatformUsers(_platformid: string) {
/*const ids = (await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.PlatformAssociations, platformid))).map(val => parseInt(val)).filter(val => !isNaN(val));
for (const id of ids) {
const profile = Server.UnifiedProfile.get(id);
if (!profile) continue;
this.setPlayerIgnored(profile);
}
}*/
// circular dep here. this will do nothing for now.
}
}

View File

@@ -22,7 +22,7 @@ export class ProfileReputationManager extends ProfileContentManager {
// deno-lint-ignore require-await
async getReputation() { // async temporary
return {
AccountId: this.profileId,
AccountId: this.profile.getId(),
Noteriety: 0.0,
CheerGeneral: 0,
CheerHelpful: 0,

View File

@@ -22,7 +22,7 @@ export class ProfileRoomsManager extends ProfileContentManager {
#rootKey = Redis.buildKey(
Redis.KeyGroups.Profiles.Root,
this.profileId.toString(),
this.profile.getId().toString(),
Redis.KeyGroups.Profiles.Rooms
);

View File

@@ -31,30 +31,30 @@ export class ProfileSettingsManager extends ProfileContentManager {
}
async getSettings() {
const settings = await Redis.Database.hgetall(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings));
const settings = await Redis.Database.hgetall(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings));
const returnSettings: Setting[] = [];
for (const key of Object.keys(settings)) returnSettings.push({ Key: key, Value: settings[key] });
return returnSettings;
}
async getSetting(key: SettingKey) {
return await Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings), key);
return await Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings), key);
}
async setSetting(key: SettingKey, value: string | number) {
await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings), key, value);
await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings), key, value);
}
async setSettingRaw(key: string, value: string | number) {
await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings), key, value);
await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings), key, value);
}
async delSetting(key: SettingKey) {
await Redis.Database.hdel(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings), key);
await Redis.Database.hdel(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings), key);
}
async delAllSettings() {
await Redis.Database.del(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId.toString(), Redis.KeyGroups.Profiles.Settings));
await Redis.Database.del(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profile.getId().toString(), Redis.KeyGroups.Profiles.Settings));
}
}

View File

@@ -15,13 +15,8 @@ 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 { Event } from "./baseevent.ts";
import { Profile } from "./profiles.ts";
import { Profile } from "./profile/base/profiles.ts";
export enum ProfileEvents {
BaseUpdated = "profile.updated"
}
export interface ProfileUpdatedEvent extends Event {
export interface ProfileUpdatedEvent {
profile: Profile
}

29
src/data/server/server.ts Normal file
View File

@@ -0,0 +1,29 @@
/* 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 { EventManager } from "../baseevent.ts";
import { CDNBase } from "../content/cdn.ts";
import { UnifiedProfileBase } from "../profile/base/profiles.ts";
import { ServerEvents } from "../server/serverevents.ts";
export class ServerBase extends EventManager<ServerEvents> {
CDN = new CDNBase();
UnifiedProfile = new UnifiedProfileBase(this);
}
const Server = new ServerBase();
export default Server;

View File

@@ -0,0 +1,25 @@
/* 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 type { RoomUpdatedEvent, SubroomUpdatedEvent } from "../content/rooms/RoomEvents.ts";
import { ProfileUpdatedEvent } from "../profileevents.ts";
export interface ServerEvents {
'profile.updated': ProfileUpdatedEvent
'room.subroom.updated': SubroomUpdatedEvent
'room.updated': RoomUpdatedEvent
}

View File

@@ -1,79 +0,0 @@
/* 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 { Config } from "../config.ts";
const log = new Logging("Steam");
const config = Config.getConfig();
interface AuthenticateUserTicketSuccess {
result: 'OK',
steamid: string,
ownersteamid: string,
vacbanned: boolean,
publisherbanned: boolean
}
interface AuthenticateUserTicketError {
errorcode: number,
errordesc: string
}
interface SteamRes {
response: {
error?: AuthenticateUserTicketError,
params?: AuthenticateUserTicketSuccess
}
}
export async function AuthenticateUserTicket(ticket: string, userid: string) {
if (!config.auth.steamkey) return true; // always authenticate if no steam API key was found
const params = new URLSearchParams();
params.append('key', config.auth.steamkey);
params.append('appid', "471710");
params.append('ticket', ticket);
try {
const res = await fetch(`https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1?${params}`);
const resjson = (await res.json()) as SteamRes;
if (resjson.response.error) {
log.w(`Steam Authentication failed: (${resjson.response.error.errorcode}) ${resjson.response.error.errordesc}`);
// add more error codes later if needed
const conditions = [
resjson.response.error.errorcode == 100
].includes(true);
if (conditions) log.w('This error indicates a client problem.');
return false;
}
log.d(JSON.stringify(resjson.response));
if (resjson.response.params) return resjson.response.params.steamid === userid && resjson.response.params.ownersteamid === userid;
else {
log.w("Steam Authentication failed: Steam response did not contain params or error! This should never be logged!");
return false;
}
} catch (err) {
log.w(`Steam Authentication failed: ${(err as Error).message}`);
return false;
}
}
export * as Steam from "./steam.ts";

View File

@@ -0,0 +1,55 @@
/* 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/>. */
interface AuthenticateUserTicketSuccess {
result: 'OK';
steamid: string;
ownersteamid: string;
vacbanned: boolean;
publisherbanned: boolean;
}
interface AuthenticateUserTicketError {
errorcode: number;
errordesc: string;
}
export interface SteamAuthRes {
response: {
error?: AuthenticateUserTicketError;
params?: AuthenticateUserTicketSuccess;
};
}
export enum SteamAuthResult {
Success,
Failure,
NotConfigured
}
interface SteamAuthBase {
valid: SteamAuthResult;
}
interface SteamAuthSuccess extends SteamAuthBase {
valid: SteamAuthResult.Success;
res: AuthenticateUserTicketSuccess;
}
interface SteamAuthFailure extends SteamAuthBase {
valid: SteamAuthResult.Failure;
res: AuthenticateUserTicketError;
}
interface SteamAuthNotConfigured extends SteamAuthBase {
valid: SteamAuthResult.NotConfigured;
}
export type SteamAuth = SteamAuthSuccess | SteamAuthFailure | SteamAuthNotConfigured;

View File

@@ -0,0 +1,34 @@
/* 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 type { PersonaState, CommunityVisibilityState } from "./steam.ts";
export interface SteamPlayer {
steamid: string;
personaname: string;
profileurl: string;
avatar: string;
avatarmedium: string;
avatarfull: string;
personastate: PersonaState;
communityvisibilitystate: CommunityVisibilityState;
profilestate?: 1;
lastlogoff?: number;
commentpermission?: 1;
loccountrycode?: string; // undocumented but is seen in API responses - may or may not be undefined
locstatecode?: string; // undocumented but is seen in API responses - may or may not be undefined
}

111
src/data/steam/steam.ts Normal file
View File

@@ -0,0 +1,111 @@
/* 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 { Config } from "../../config/config.ts";
import { SteamAuth, SteamAuthResult, SteamAuthRes } from "./SteamAuthTypes.ts";
import { SteamPlayer } from "./SteamCommonTypes.ts";
const log = new Logging("Steam");
const config = Config.getConfig();
function buildSteamUrl(steaminterface: string, endpoint: string) {
return `https://api.steampowered.com/${steaminterface}/${endpoint}`;
}
export enum PersonaState {
Offline,
Online,
Busy,
Away,
Snooze,
LookingToTrade,
LookingToPlay
}
export enum CommunityVisibilityState {
NotVisible,
PubliclyVisible = 3
}
class SteamBase {
async GetPlayerSummaries(steamids: string[]) {
if (!config.auth.steamkey) return null;
const params = new URLSearchParams();
params.append('key', config.auth.steamkey);
params.append('steamids', steamids.join(','))
try {
const res = await fetch(`${buildSteamUrl('ISteamUser', 'GetPlayerSummaries/v2')}?${params}`);
if (res.status !== 200) return null;
const resjson = await res.json() as { response: { players: SteamPlayer[] } };
return resjson.response.players;
} catch (err) {
log.e(`Could not fetch Steam player summaries: ${(err as Error).stack}`);
return null;
}
}
async AuthenticateUserTicket(ticket: string, userid: string): Promise<SteamAuth> {
if (!config.auth.steamkey) return { valid: SteamAuthResult.NotConfigured }; // always authenticate if no steam API key was found
const params = new URLSearchParams();
params.append('key', config.auth.steamkey);
params.append('appid', "471710");
params.append('ticket', ticket);
try {
const res = await fetch(`${buildSteamUrl('ISteamUserAuth', 'AuthenticateUserTicket/v1')}?${params}`);
const resjson = (await res.json()) as SteamAuthRes;
if (resjson.response.error) {
log.w(`Steam Authentication failed: (${resjson.response.error.errorcode}) ${resjson.response.error.errordesc}`);
// add more error codes later if needed
const conditions = [
resjson.response.error.errorcode == 100
].includes(true);
if (conditions) log.w('This error indicates a client problem.');
return { valid: SteamAuthResult.Failure, res: resjson.response.error };
}
//log.d(JSON.stringify(resjson.response));
if (resjson.response.params) {
// since rec room is not eligible for family sharing on Steam
const valid = resjson.response.params.steamid === userid && resjson.response.params.ownersteamid === userid;
if (valid) return { valid: SteamAuthResult.Success, res: resjson.response.params }
else throw new Error('`ownersteamid` is not equal to `steamid`, report me to GC devs!');
}
else {
log.w("Steam Authentication failed: Steam response did not contain params or error! This should never be logged!");
return { valid: SteamAuthResult.Failure, res: { errorcode: -1, errordesc: 'Steam response error' } };
}
} catch (err) {
log.w(`Steam Authentication failed: ${(err as Error).message}`);
return { valid: SteamAuthResult.Failure, res: { errorcode: -1, errordesc: 'Steam response error' } };
}
}
}
const Steam = new SteamBase();
export default Steam;

View File

@@ -17,24 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { Redis } from "../db.ts";
import * as JsonWebToken from "@gz/jwt";
import { Config } from "../config.ts";
import { Profile } from "./profiles.ts";
import { TokenBaseFormat } from "../apiutils.ts";
type UserInitOptions = {
client_id: string;
pubkey: string;
};
export enum AuthType {
Game,
Web,
}
export interface UserTokenFormat extends TokenBaseFormat {
sub: string;
typ: AuthType.Web;
}
import { Config } from "../config/config.ts";
import { Profile } from "./profile/base/profiles.ts";
import type { UserTokenFormat } from "./auth/TokenBaseFormat.ts";
import { UserInitOptions, AuthType } from "./UserTypes.ts";
const config = Config.getConfig();

View File

@@ -16,7 +16,7 @@ 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 { Redis } from "ioredis";
import * as Config from "./config.ts";
import * as Config from "./config/config.ts";
import Logging from "@proxnet/undead-logging";
import chalk from "npm:chalk@^5.3.0";
@@ -110,6 +110,9 @@ export const KeyGroups = {
Root: "room",
PlayerDorms: "player-dormids"
},
Subrooms: {
Root: "subroom"
},
Operators: "operators",
Users: {
Root: "users",

View File

@@ -1,62 +0,0 @@
/* 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 * as discord from "discord.js";
import { Config } from "./config.ts";
import Logging from "@proxnet/undead-logging";
const log = new Logging("Discord");
const config = Config.getConfig();
if (typeof config == "undefined") {
log.e(`Cannot start: Discord configuration is unavailable`);
Deno.exit(1);
}
export const client = new discord.Client({
intents: [
discord.GatewayIntentBits.Guilds,
discord.GatewayIntentBits.GuildPresences,
],
});
client.once(discord.Events.ClientReady, (client) => {
log.i(`Logged in to Discord as "${client.user.tag}"`);
client.user?.setActivity(config.public.motd, {
type: discord.ActivityType.Custom,
});
});
let shuttingDown = false;
Deno.addSignalListener("SIGINT", () => {
if (client.readyTimestamp == null) return;
if (shuttingDown) return;
shuttingDown = true;
log.n("Disconnecting from Discord");
client.destroy();
});
export function login() {
if (config.discord?.token == Config.defaultConfig.discord?.token) {
log.i("Discord not configured, ignoring");
return;
}
log.i(`Creating Discord connection..`);
client.login(config.discord?.token);
}
export * as Discord from "./discord.ts";

View File

@@ -16,42 +16,44 @@ 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 * as Log from "@proxnet/undead-logging";
import * as Config from "./config.ts";
import * as Config from "./config/config.ts";
import { Database } from "./db.ts";
import { APIUtils, ProfileTokenSchema } from "./apiutils.ts";
import { Discord } from "./discord.ts";
import { generateRandomString } from "./apiutils.ts";
import { APIUtils } from "./apiutils.ts";
import { ProfileTokenSchema } from "./data/auth/TokenSchema.ts";
import { generateRandomString } from "./utils.ts";
import express from "express";
import { decode } from "@gz/jwt";
import { ProfileTokenFormat } from "./data/profiles.ts";
import { SocketHandoff } from "./socket/handoff.ts";
import { SignalRSocketHandler } from "./socket/socket.ts";
import { GameConfigs } from "./data/config.ts";
import { GameConfigs } from "./data/config/GameConfigs.ts";
import { getVersion } from "./ver.ts";
import Rooms from "./data/content/rooms.ts";
import RoomsMisc from "./data/content/rooms/Rooms.ts";
import { PushNotificationId } from "./socket/types.ts";
import Server from "./data/server.ts";
import Server from "./data/server/server.ts";
import type { ProfileTokenFormat } from "./data/auth/TokenBaseFormat.ts";
import { addWatchdogListener } from "./watchdog.ts";
const instanceId = generateRandomString(64);
const log = new Log.default("Main");
Log.LoggingConfiguration.logTiming = Log.LogTiming.Deferred;
log.i(`Galvanic Corrosion '${await getVersion()}'`);
const config = Config.getConfig();
if (typeof config == "undefined") {
log.e("Cannot start: Configuration was not found.");
Deno.exit(5);
}
if (config.auth.secret == Config.defaultConfig.auth.secret) {
log.e(`Cannot start: Auth secret is default. Please change 'auth.secret' in 'config.json'`);
Deno.exit(5);
}
if (config.public.serverId == Config.defaultConfig.public.serverId) {
log.e(`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`);
Deno.exit(5);
function exitError(err: string, code?: number) {
log.e(`Cannot start: ${err}`);
if (code) Deno.exit(code);
else Deno.exit(5);
}
if (typeof config == "undefined")
exitError("Configuration was not found.");
if (config.auth.secret == Config.defaultConfig.auth.secret)
exitError(`Auth secret is default. Please change 'auth.secret' in 'config.json'`);
if (config.public.serverId == Config.defaultConfig.public.serverId)
exitError(`Server ID is default. Please change 'public.serverId' in 'config.json'`);
Log.MessageTypeVisibility.Network = config.logging.network;
Log.MessageTypeVisibility.Debug = config.logging.debug;
@@ -59,8 +61,7 @@ Log.MessageTypeVisibility.Debug = config.logging.debug;
try {
Database.connect();
} catch (err) {
log.e(`Cannot start: Redis could not be initialized. ${err}`);
Deno.exit(1);
exitError(`Redis could not be initialized. ${(err as Error).stack}`);
}
const app = express();
@@ -118,7 +119,7 @@ app.use((rq: express.Request, rs: express.Response) => {
rs.json(APIUtils.genericResponseFormat(true, "Endpoint not found. Check your syntax and/or method."));
});
if (!(await Rooms.generateBuiltinRooms())) log.i(`Generated built-in rooms`);
if (!(await RoomsMisc.generateBuiltinRooms())) log.i(`Generated built-in rooms`);
await Server.CDN.ensureUserDirectory();
try {
@@ -227,6 +228,7 @@ try {
Deno.addSignalListener("SIGINT", () => {
for (const socket of Server.UnifiedProfile.getAllSockets()) socket.sendNotification(PushNotificationId.ModerationQuitGame); // untested
});
addWatchdogListener();
if (!(await Server.UnifiedProfile.existsByName("Coach"))) Server.UnifiedProfile.create({ username: "Coach", id: 1 }); // create Coach id 1 if they do not exist
if (!(await Server.UnifiedProfile.existsByName("Server"))) Server.UnifiedProfile.create({ username: "Server", id: 2 }); // create Server id 2 if they do not exist
@@ -246,5 +248,3 @@ try {
log.e(`Cannot start: Network could not be initalized. ${err}`);
Deno.exit(1);
}
Discord.login();

View File

@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import { Profile } from "../../data/profiles.ts";
import { Profile } from "../../data/profile/base/profiles.ts";
import { z } from "zod";
import { AuthType } from "../../data/users.ts";
import Server from "../../data/server.ts";
import { AuthType } from "../../data/UserTypes.ts";
import Server from "../../data/server/server.ts";
export const route = APIUtils.createRouter("/account");

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/parentalcontrol');

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter("/announcement");

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
import { AvatarSettings } from "../../data/profile/avatar.ts";

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter("/challenge");

View File

@@ -16,8 +16,8 @@ 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 { APIUtils } from "../../apiutils.ts";
import { ObjectiveType } from "../../data/objectives.ts";
import { AuthType } from "../../data/users.ts";
import { ObjectiveType } from "../../data/content/ObjectiveTypes.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/checklist');

View File

@@ -16,8 +16,8 @@ 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 { APIUtils } from "../../apiutils.ts";
import { Config } from "../../config.ts";
import { AuthType } from "../../data/users.ts";
import { Config } from "../../config/config.ts";
import { AuthType } from "../../data/UserTypes.ts";
const config = Config.getConfig();

View File

@@ -16,14 +16,14 @@ 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 { APIUtils } from "../../apiutils.ts";
import { GameConfigs } from "../../data/config.ts";
import { getPublicConfig } from "../../data/config/PublicConfig.ts";
export const route = APIUtils.createRouter("/config");
const rateLimit = new APIUtils.RateLimiter();
route.router.get("/v2", rateLimit.middle(), (_rq, rs) => {
const config = GameConfigs.getConfig();
const config = getPublicConfig();
if (config == null) rs.sendStatus(500);
else rs.json(config);
});

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/consumables');

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/equipment');

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { GameConfigs } from "../../data/config.ts";
import { GameConfigs } from "../../data/config/GameConfigs.ts";
export const route = APIUtils.createRouter("/gameconfigs");

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/images');
@@ -28,3 +28,16 @@ route.router.get('/v2/named',
APIUtils.emptyArrayResponse
);
route.router.post('/v4/uploadsaved',
APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Game),
APIUtils.requestDebug,
(_rq, rs) => {
rs.json({ImageName:"notsaved.png"});
},
);

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
export const route = APIUtils.createRouter('/objectives');

View File

@@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
import Server from "../../data/server.ts";
import Server from "../../data/server/server.ts";
export const route = APIUtils.createRouter("/playerReputation");

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter('/playerevents');

View File

@@ -18,9 +18,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import Logging from "@proxnet/undead-logging";
import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import { z } from "zod";
import Server from "../../data/server.ts";
import Server from "../../data/server/server.ts";
const log = new Logging("ProgressionRoute");

View File

@@ -16,8 +16,8 @@ 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 { APIUtils } from "../../apiutils.ts";
import { Config } from "../../config.ts";
import { AuthType } from "../../data/users.ts";
import { Config } from "../../config/config.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter("/quickPlay");

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
export const route = APIUtils.createRouter("/relationships");

View File

@@ -17,13 +17,14 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts";
import Rooms from "../../data/content/rooms.ts";
import { FactoryMode, RoomDataTypes, WriteMode } from "../../data/content/rooms/DataTypes.ts";
import { AuthType } from "../../data/users.ts";
import RoomsMisc from "../../data/content/rooms/Rooms.ts";
import { FactoryMode, RoomDataTypes, WriteMode } from "../../data/content/rooms/base/DataTypes.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
import { RoomFactory } from "../../data/content/rooms/RoomFactory.ts";
import { SubroomFactory } from "../../data/content/rooms/SubroomFactory.ts";
import Logging from "@proxnet/undead-logging";
import Rooms from "../../data/content/rooms/base/RoomsBase.ts";
const log = new Logging("RoomsRoute");
@@ -83,7 +84,7 @@ route.router.get('/v1/hot',
async (_rq, rs) => {
// temporary: return all public AG rooms for testing
const rooms = await Rooms.getAllBuiltinRoomGenerations();
const rooms = await RoomsMisc.getAllBuiltinRoomGenerations();
rs.json(rooms.map(room => room.Room).filter(room => room.Accessibility == RoomDataTypes.RoomAccessibility.Public));
},
@@ -95,7 +96,7 @@ route.router.get('/v2/baserooms',
APIUtils.AuthenticationType(AuthType.Game),
async (_rq, rs) => {
const rooms = await Rooms.getAllBuiltinRoomGenerations();
const rooms = await RoomsMisc.getAllBuiltinRoomGenerations();
rs.json(rooms.map(room => room.Room).filter(room => room.CloningAllowed));
},
@@ -120,7 +121,7 @@ route.router.get('/v2/name/:name',
rs.json(room.Room);
return;
} else if (rq.params.name == 'DormRoom') {
const dorm = await Rooms.getProfileDormDefault(rs.locals.profile);
const dorm = await RoomsMisc.getProfileDormDefault(rs.locals.profile);
if (dorm) rs.json(dorm.Room);
else rs.sendStatus(404);
return;
@@ -150,7 +151,7 @@ route.router.get('/v1/agRoomIds',
async (_rq, rs) => {
const rooms = await Rooms.getAllBuiltinRoomGenerations();
const rooms = await RoomsMisc.getAllBuiltinRoomGenerations();
rs.json(rooms.map(det => det.Room.RoomId));
},
@@ -175,7 +176,7 @@ route.router.post('/v1/clone',
async (rq: express.Request<NoBody, NoBody, CloneRoomBody>, rs: express.Response) => {
const room = await Rooms.cloneRoom(rq.body.RoomId, rq.body.Name, rs.locals.profile);
const room = await RoomsMisc.cloneRoom(rq.body.RoomId, rq.body.Name, rs.locals.profile);
const masterRoomFactory = await new RoomFactory({ id: rq.body.RoomId }).init();
@@ -231,7 +232,6 @@ route.router.post('/v4/saveData',
}
const subroomFactory = await new SubroomFactory({
roomId: currentInstance.roomId,
subroomId: rq.body.RoomSceneId,
factoryMode: FactoryMode.Write,
writeMode: WriteMode.Overwrite
@@ -249,10 +249,6 @@ route.router.post('/v4/saveData',
await subroomFactory.write();
rs.json(subroomFactory.export());
currentInstance.dataBlob = newFilename;
currentInstance.updatePlayers();
Rooms.socketUpdateRoom(currentInstance);
}
},

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import Logging from "@proxnet/undead-logging";
import { APIUtils, NoBody } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import z from "zod";
import express from "express";

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils } from "../../apiutils.ts";
import express from "express";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import { StorefrontBalanceType } from "../../data/content/storefronts.ts";
export const route = APIUtils.createRouter('/storefronts');

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter("/account");

View File

@@ -16,7 +16,7 @@ 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 { APIUtils } from "../../apiutils.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
export const route = APIUtils.createRouter("/cachedlogin");

View File

@@ -18,14 +18,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import { decode } from "@gz/jwt";
import { Config } from "../../config.ts";
import { Config } from "../../config/config.ts";
import Logging from "@proxnet/undead-logging";
import { z } from "zod";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import { Redis } from "../../db.ts";
import { validVersions } from "../api/versioncheck.ts";
import { Steam } from "../../data/steam.ts";
import Server from "../../data/server.ts";
import Steam from "../../data/steam/steam.ts";
import Server from "../../data/server/server.ts";
import { DeviceClass } from "../../data/live/types.ts";
import { SteamAuthResult } from "../../data/steam/SteamAuthTypes.ts";
const config = Config.getConfig();
@@ -124,7 +126,7 @@ route.router.post("/token",
});
}
const conditionsMet = ![
const conditions = [
rq.body.client_id === "recroom",
rq.body.platform === "0",
validVersions.includes(rq.body.ver),
@@ -136,7 +138,8 @@ route.router.post("/token",
!(rq.body.time.length > 32),
!(rq.body.asid.length > 32),
SteamPlatformParamsSchema.safeParse(JSON.parse(rq.body.platform_auth)).success
].includes(false);
];
const conditionsMet = !conditions.includes(false);
if (!conditionsMet) {
requestFailed();
@@ -167,13 +170,15 @@ route.router.post("/token",
const platformAuth = (JSON.parse(rq.body.platform_auth)) as SteamPlatformParams;
let platformid: string | null;
if (config.auth.steamkey) {
const steamAuthed = await Steam.AuthenticateUserTicket(platformAuth.Ticket, rq.body.platform_id);
if (!steamAuthed) {
const steamAuth = await Steam.AuthenticateUserTicket(platformAuth.Ticket, rq.body.platform_id);
if (steamAuth.valid == SteamAuthResult.Failure) {
requestFailed();
return;
}
}
} else if (steamAuth.valid == SteamAuthResult.Success) platformid = steamAuth.res.steamid;
else platformid = null;
} else platformid = null;
if (isNaN(targetAccount)) {
requestFailed();
@@ -187,8 +192,8 @@ route.router.post("/token",
}
rs.locals.user.addAssociatedDeviceId(rq.body.device_id);
rs.locals.user.addAssociatedPlatformId(rq.body.platform_id);
Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.PlatformAssociations, rq.body.platform_id), targetAccount);
if (platformid) rs.locals.user.addAssociatedPlatformId(platformid);
if (platformid) Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.PlatformAssociations, platformid), targetAccount);
const profile = Server.UnifiedProfile.get(targetAccount);
if (!profile) {
@@ -196,16 +201,22 @@ route.router.post("/token",
return;
}
const deviceClass = Number(rq.body.device_class);
if (typeof DeviceClass[deviceClass] == 'undefined') {
requestFailed();
return;
}
const details = await profile.export();
log.i(`Player ${details?.username} "${details?.displayName}" (${profile.getId()}) logged in`);
const token = await profile.getToken();
const token = await profile.Auth.getToken();
rs.json({
access_token: token,
refresh_token: token,
});
await profile.setKnownDeviceClass(Number(rq.body.device_class));
await profile.setKnownDeviceClass(deviceClass);
nxt();
},

View File

@@ -17,8 +17,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils } from "../apiutils.ts";
import { File } from "../data/content/cdn.ts";
import Server from "../data/server.ts";
import { AuthType } from "../data/users.ts";
import Server from "../data/server/server.ts";
import { AuthType } from "../data/UserTypes.ts";
import { route as ConfigRoute } from "./cdn/config.ts";
import express from "express";
import { Buffer } from "node:buffer";

View File

@@ -19,7 +19,7 @@ import Logging from "@proxnet/undead-logging";
import { APIUtils, NoBody } from "../../apiutils.ts";
import Matchmaking from "../../data/live/base.ts";
import { MatchmakingErrorCode } from "../../data/live/types.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
import { z } from "zod";
@@ -33,10 +33,10 @@ interface MatchmakingParams {
}
const ProperCaseBooleanSchema = z.preprocess((val) => {
if (val === "True") return true;
if (val === "False") return false;
if (typeof val === "boolean") return val; // allow raw booleans too
return val; // will fail validation
if (val === "True") return true;
if (val === "False") return false;
if (typeof val === "boolean") return val;
return val;
}, z.boolean());
interface MatchmakingOptions {

View File

@@ -20,10 +20,10 @@ import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express";
import Matchmaking from "../../data/live/base.ts";
import Presence, { PresenceExport } from "../../data/live/presence.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import { PlayerStatusVisibility, VRMovementMode } from "../../data/live/types.ts";
import { SettingKey } from "../../data/content/settings.ts";
import Server from "../../data/server.ts";
import Server from "../../data/server/server.ts";
export const route = APIUtils.createRouter('/player');

View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils } from "../../apiutils.ts";
import Instances from "../../data/live/instances.ts";
import { AuthType } from "../../data/users.ts";
import { AuthType } from "../../data/UserTypes.ts";
import express from "express";
export const route = APIUtils.createRouter('/room');

View File

@@ -16,9 +16,10 @@ 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 { APIUtils } from "../apiutils.ts";
import { Config } from "../config.ts";
import { Config } from "../config/config.ts";
import type { GalvanicConfiguration } from "../config/GalvanicConfiguration.ts";
const config = Config.getConfig() as Config.GalvanicConfiguration;
const config = Config.getConfig() as GalvanicConfiguration;
const protocol = config.web.api.securepublichost ? "https" : "http";
export const route = APIUtils.createRouter("/ns");

View File

@@ -17,12 +17,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { APIUtils, NoBody } from "../apiutils.ts";
import { AuthType } from "../data/users.ts";
import { Buffer } from "node:buffer";
import multer from "multer";
import { FileType } from "../data/content/cdn.ts";
import express from "express";
import Server from "../data/server.ts";
import { AuthType } from "../data/UserTypes.ts";
import Server from "../data/server/server.ts";
export const route = APIUtils.createRouter("/storage");

View File

@@ -18,12 +18,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils, getSrcIpDefault, NoBody } from "../apiutils.ts";
// @ts-types = "npm:@types/express"
import express from "express";
import { User, UserTokenFormat } from "../data/users.ts";
import { Config } from "../config.ts";
import { User } from "../data/users.ts";
import { Config } from "../config/config.ts";
import crypto from "node:crypto";
import Logging from "@proxnet/undead-logging";
import { decode } from "@gz/jwt";
import z from "zod";
import type { UserTokenFormat } from "../data/auth/TokenBaseFormat.ts";
const log = new Logging("UserRoute");

View File

@@ -16,8 +16,8 @@ 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 { APIUtils } from "../apiutils.ts";
import { Config } from "../config.ts";
import { AuthType } from "../data/users.ts";
import { Config } from "../config/config.ts";
import { AuthType } from "../data/UserTypes.ts";
import { SocketHandoff } from "./handoff.ts";
const config = Config.getConfig();

View File

@@ -15,7 +15,7 @@ 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 { Profile } from "../data/profiles.ts";
import type { Profile } from "../data/profile/base/profiles.ts";
import Logging from "@proxnet/undead-logging";
import {
CompletionMessage,
@@ -34,9 +34,8 @@ import {
import { SocketTarget } from "./targets/targetbase.ts";
import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts";
import Presence from "../data/live/presence.ts";
import Matchmaking from "../data/live/base.ts";
const logmessages = false;
const logmessages = true;
export class SignalRSocketHandler {
@@ -62,13 +61,11 @@ export class SignalRSocketHandler {
this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget(this));
for (const target of this.#Targets.values()) target.onInit();
this.#PeriodicalId = setInterval(async () => {
if (this.#killed) return;
if (this.#socket.readyState !== this.#socket.CLOSED) {
const pres = await Presence.get(this.#profile);
this.sendNotification("PresenceUpdate", await pres.export());
this.sendRaw({ type: 6 });
}
if (this.#socket.readyState == this.#socket.OPEN) await this.presenceUpdate();
}, 8000);
this.#socket.onclose = (ev) => {
@@ -77,6 +74,12 @@ export class SignalRSocketHandler {
}
async presenceUpdate() {
const pres = await Presence.get(this.#profile);
this.sendNotification("PresenceUpdate", await pres.export());
this.sendRaw({ type: 6 });
}
async #dispatchTarget<T = unknown>(target: string, args: unknown): Promise<TargetResult> {
if (this.#killed) {
const error = "Tried to dispatch socket target on dead socket";
@@ -161,19 +164,24 @@ export class SignalRSocketHandler {
this.#socket.addEventListener('close', this.destroy(this, true));
}
destroy(sock: SignalRSocketHandler, internal: boolean | undefined = false) {
return () => {
sock.#killed = true;
clearInterval(sock.#PeriodicalId);
sock.sendRaw({ type: 7, error: "Socket closed" });
if (!internal) sock.#socket.close();
sock.#log.i(`Closed socket`);
sock.#profile.clearSocketHandler();
destroy(handler: SignalRSocketHandler, internal: boolean | undefined = false) {
return (ev: CloseEvent) => {
handler.#killed = true;
clearInterval(handler.#PeriodicalId);
for (const target of sock.#Targets.values()) target.destroy();
let errorReason = "Socket closed by server";
this.#log.d(`Socket close code: ${ev.code}`);
if (ev.reason.includes('Bye!')) errorReason = "Socket closed by client request";
handler.sendRaw({ type: 7, error: errorReason });
if (!internal) handler.#socket.close();
handler.#log.i(`Closed socket`);
handler.#profile.clearSocketHandler();
for (const target of handler.#Targets.values()) target.onDestroy();
this.#profile.getInstance()?.removePlayer(this.#profile);
Matchmaking.deleteLoginLock(this.#profile);
}
}

View File

@@ -17,10 +17,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod";
import { SocketTarget } from "./targetbase.ts";
import { SelfAccountExport } from "../../data/profiles.ts";
import { ProfileEvents, ProfileUpdatedEvent } from "../../data/profileevents.ts";
import type { Profile } from "../../data/profile/base/profiles.ts";
import type { ProfileUpdatedEvent } from "../../data/profileevents.ts";
import { PushNotificationId } from "../types.ts";
import Server from "../../data/server.ts";
import Server from "../../data/server/server.ts";
const ArgumentSchema = z.object({
PlayerIds: z.array(z.number())
@@ -28,46 +28,30 @@ const ArgumentSchema = z.object({
export class PlayerSocketSubscriptionTarget extends SocketTarget {
updateSocket(profile: SelfAccountExport) {
this.socket.sendNotification(PushNotificationId.SubscriptionUpdateProfile, profile);
async updateSocket(profile: Profile) {
this.socket.sendNotification(PushNotificationId.SubscriptionUpdateProfile, await profile.export() || undefined);
}
subscriptions: { id: number, callback: (ev: unknown) => void }[] = [];
setSubscriptions(subs: number[]) {
this.clearSubscriptions();
for (const id of subs) {
const profile = Server.UnifiedProfile.get(id);
if (!profile) continue;
this.subscriptions.push({ id: id, callback: profile
.on<ProfileUpdatedEvent>(ProfileEvents.BaseUpdated, (async ev => {
const exported = await ev.profile.export();
if (exported) this.updateSocket(exported);
}
)) });
}
subscriptions: number[] = [];
#callback = (ev: ProfileUpdatedEvent) => {
if (this.subscriptions.includes(ev.profile.getId()))
this.updateSocket(ev.profile);
}
clearSubscriptions() {
for (const sub of this.subscriptions) {
const profile = Server.UnifiedProfile.get(sub.id);
if (profile)
profile.off(ProfileEvents.BaseUpdated, sub.callback);
}
override onInit() {
Server.on('profile.updated', this.#callback);
}
override destroy() {
this.clearSubscriptions();
override onDestroy() {
Server.off('profile.updated', this.#callback);
}
// deno-lint-ignore require-await
override async exec(args: unknown) {
const parsed = ArgumentSchema.safeParse(args);
if (parsed.success) {
this.setSubscriptions(parsed.data.PlayerIds);
this.subscriptions = parsed.data.PlayerIds;
return;
} else throw new Error("Invalid arguments");
}

View File

@@ -15,7 +15,7 @@ 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 { SignalRSocketHandler } from "../socket.ts";
import type { SignalRSocketHandler } from "../socket.ts";
export class SocketTarget {
@@ -25,7 +25,11 @@ export class SocketTarget {
this.socket = socket;
}
destroy() {
onInit() {
return;
}
onDestroy() {
return;
}

View File

@@ -15,7 +15,7 @@ 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 { Profile } from "../data/profiles.ts";
import { Profile } from "../data/profile/base/profiles.ts";
import { User } from "../data/users.ts";
declare global {

View File

@@ -15,7 +15,7 @@ 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 { ProfileTokenFormat } from "../data/profiles.ts";
import type { ProfileTokenFormat } from "../data/auth/TokenBaseFormat.ts";
declare module 'node:http' {
interface IncomingMessage {

28
src/utils.ts Normal file
View File

@@ -0,0 +1,28 @@
/* 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/>. */
export function generateRandomString(length: number) {
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let randomString = "";
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
randomString += characters.charAt(randomIndex);
}
return randomString;
}

34
src/watchdog.ts Normal file
View File

@@ -0,0 +1,34 @@
/* 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, { LoggingConfiguration, LogTiming } from "@proxnet/undead-logging";
const log = new Logging("Watchdog");
let added = false;
export function addWatchdogListener() {
if (added) return;
added = true;
Deno.addSignalListener('SIGINT', () => {
LoggingConfiguration.logTiming = LogTiming.Sync;
setTimeout(() => {
log.e(`Server took too long (60s) to shut down! Exiting.`);
Deno.exit(2);
}, 60000);
});
}