Add backend to repository

This commit is contained in:
nthouliss 2023-01-16 12:02:32 +11:00
parent 13de88a77f
commit 163d11432e
41 changed files with 3724 additions and 0 deletions

5
backend/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
npm-debug.log
Dockerfile
.git
.gitignore

44
backend/.eslintrc.json Normal file
View File

@ -0,0 +1,44 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"airbnb-base",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript"
],
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"quotes": [ 1, "double", "avoid-escape" ],
"import/extensions": [
"error",
"ignorePackages",
{
"js": "never",
"jsx": "never",
"ts": "never",
"tsx": "never"
}
]
},
"globals": {
"__dirname": true
},
"settings": {
"import/resolver": {
"node": {
"extensions": [".js", ".jsx", ".ts", ".tsx"]
}
}
}
}

24
backend/.gitattributes vendored Normal file
View File

@ -0,0 +1,24 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
* text eol=lf
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.c text
*.h text
# Declare files that will always have CRLF line endings on checkout.
*.sln text eol=crlf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
# Force batch scripts to always use CRLF line endings so that if a repo is accessed
# in Windows via a file share from Linux, the scripts will work.
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
# Force bash scripts to always use LF line endings so that if a repo is accessed
# in Unix via a file share from Windows, the scripts will work.
*.sh text eol=lf

117
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,117 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Local Terraform Files
**.tfstate*
terraform-provider**
lock.json
.terraform/
#batect
.batect/caches*
# Typescript
build
build/**

30
backend/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
FROM node:14.18.1-alpine3.13 AS builder
RUN mkdir /home/node/app/ && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY --chown=node:node package.json ./
COPY --chown=node:node yarn.lock ./
USER node
RUN yarn install --non-interactive --dev && yarn cache clean
COPY ./src/ ./src
COPY ./tsconfig.json ./
RUN yarn run build
FROM builder AS install
ENV NODE_ENV production
WORKDIR /home/node/app
RUN rm -rf ./node_modules && yarn install --non-interactive --prod --frozen-lockfile && yarn cache clean
FROM node:14.18.1-alpine3.13
USER node
ENV NODE_ENV production
WORKDIR /home/node/app
COPY --chown=node:node package.json ./
COPY --chown=node:node yarn.lock ./
COPY --chown=node:node --from=builder /home/node/app/build ./build
COPY --chown=node:node --from=install /home/node/app/node_modules ./node_modules
CMD ["node", "--es-module-specifier-resolution=node", "./build/index.js"]

26
backend/Makefile Normal file
View File

@ -0,0 +1,26 @@
.SILENT:
## RECOMMENDED
obr:
docker compose run --rm --service-ports --name "obr-service" owlbearrodeo
## DEVELOPER HELPERS
new:
docker compose run --service-ports --name "obr-service" owlbearrodeo
build:
docker compose build --no-cache owlbearrodeo
start:
docker start obr-service
stop:
docker stop obr-service
dev:
docker compose rm -s -f owlbearrodeo && make start
remove:
docker rm obr-service

7
backend/README.md Normal file
View File

@ -0,0 +1,7 @@
# Owlbear Broker
Node.js backend for Owlbear Rodeo
## Requirements:
- Docker

View File

@ -0,0 +1,10 @@
services:
owlbearrodeo:
build: ./
stop_grace_period: 30s
init: true
ports:
- 9000:9000
environment:
PORT: 9000
ALLOW_ORIGIN: ".*"

49
backend/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "owlbear-broker",
"version": "2.0.0",
"description": "",
"main": "./src/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc --project ./",
"start:dev": "node --es-module-specifier-resolution=node -r dotenv/config ./build/index.js",
"start": "node --es-module-specifier-resolution=node ./build/index.js",
"lint": "eslint src/**/*.ts"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mitchemmc/owlbear-rodeo.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/mitchemmc/owlbear-rodeo/issues"
},
"homepage": "https://github.com/mitchemmc/owlbear-rodeo#readme",
"dependencies": {
"bcrypt": "^5.0.0",
"cors": "^2.8.5",
"deep-diff": "^1.0.2",
"express": "^4.17.3",
"helmet": "^5.0.2",
"lodash.get": "^4.4.2",
"socket.io": "^4.5.4",
"socket.io-msgpack-parser": "^3.0.2"
},
"devDependencies": {
"@types/bcrypt": "^3.0.0",
"@types/component-emitter": "^1.2.11",
"@types/cors": "^2.8.12",
"@types/deep-diff": "^1.0.1",
"@types/express": "^4.17.13",
"@types/lodash.get": "^4.4.6",
"@types/node": "^15.6.1",
"@typescript-eslint/eslint-plugin": "^5.18.0",
"dotenv": "^16.0.0",
"eslint": "^8.12.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.26.0",
"typescript": "^4.9.3"
}
}

View File

@ -0,0 +1,56 @@
import { Response, Request, NextFunction, Router } from "express";
export enum Methods {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
}
interface IRoute {
path: string;
method: Methods;
handler: (
req: Request,
res: Response,
next: NextFunction
) => void | Promise<void>;
localMiddleware: ((
req: Request,
res: Response,
next: NextFunction
) => void)[];
}
export default abstract class Controller {
public router: Router = Router();
public abstract path: string;
protected routes: Array<IRoute> = [];
public setRoutes = (): Router => {
for (const route of this.routes) {
for (const mw of route.localMiddleware) {
this.router.use(route.path, mw);
}
switch (route.method) {
case "GET":
this.router.get(route.path, route.handler);
break;
case "POST":
this.router.post(route.path, route.handler);
break;
case "PUT":
this.router.put(route.path, route.handler);
break;
case "DELETE":
this.router.delete(route.path, route.handler);
break;
default:
console.log("not a valid method");
break;
}
}
return this.router;
};
}

View File

@ -0,0 +1,30 @@
import { NextFunction, Request, Response } from "express";
import Controller, { Methods } from "./Controller";
export default class HealthcheckController extends Controller {
path = "/";
routes = [
{
path: "/",
method: Methods.GET,
handler: this.handleHome.bind(this),
localMiddleware: []
},
{
path: "/health",
method: Methods.GET,
handler: this.handleHealthcheck.bind(this),
localMiddleware: [],
},
];
async handleHome(req: Request, res: Response, next: NextFunction): Promise<void> {
res.sendStatus(200);
}
async handleHealthcheck(req: Request, res: Response, next: NextFunction): Promise<void> {
res.sendStatus(200);
}
}

View File

@ -0,0 +1,36 @@
import { NextFunction, Request, Response } from "express";
import IceServer from "../entities/IceServer";
import Controller, { Methods } from "./Controller";
export default class IceServerController extends Controller {
iceServer: IceServer;
path = "/";
routes = [
{
path: "/iceservers",
method: Methods.GET,
handler: this.handleIceServerConnection.bind(this),
localMiddleware: [],
},
];
constructor(iceServer: IceServer) {
super();
this.iceServer = iceServer;
}
async handleIceServerConnection(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const servers = await this.iceServer.getIceServers();
res.send(JSON.stringify(servers));
} catch (error) {
res.status(500).send({ error });
}
}
}

View File

@ -0,0 +1,33 @@
import { Application, RequestHandler } from "express";
import http, { Server } from "http";
import Controller from "../controllers/Controller";
export default class AppServer extends Server {
private app: Application;
private readonly port: string | number;
constructor(app: Application, port: string | number) {
super();
this.app = app;
this.port = port;
}
public run(): http.Server {
return this.app.listen(this.port, () => {
console.log(`The server is running on port ${this.port}`);
});
}
public loadMiddleware(middlewares: Array<RequestHandler>): void {
middlewares.forEach((middleware) => {
this.app.use(middleware);
});
}
public loadControllers(controllers: Array<Controller>): void {
controllers.forEach((controller) => {
this.app.use(controller.path, controller.setRoutes());
});
}
}

View File

@ -0,0 +1,16 @@
import bcrypt from "bcrypt";
export default class Auth implements Auth {
async createPasswordHash(
password: string,
saltRounds: number = 10
): Promise<string> {
const hash = await bcrypt.hash(password, saltRounds);
return hash;
}
async checkPassword(password: string, hash: string): Promise<boolean> {
const result = await bcrypt.compare(password, hash);
return result;
}
}

View File

@ -0,0 +1,65 @@
import { PlayerState } from "../types/PlayerState";
import { PartyState } from "../types/PartyState";
import { MapState } from "../types/MapState";
import { Manifest } from "../types/Manifest";
import { Map } from "../types/Map";
export default class Game {
gameId: string;
partyState: PartyState;
passwordHash: string;
state: Record<string, MapState | Manifest | Map>;
constructor(gameId: string, hash: string) {
this.gameId = gameId;
this.partyState = {};
this.passwordHash = hash;
this.state = {};
}
getPartyState(): PartyState {
// const result = await this.redis.json_hgetall(`game:${gameId}:partyState`);
const result = this.partyState;
return result;
}
getGamePasswordHash(): string {
// const result = await this.redis.get(`game:${gameId}:passwordHash`);
const result = this.passwordHash;
return result;
}
setGamePasswordHash(hash: string): void {
// const result = await this.redis.set(`game:${gameId}:passwordHash`, hash);
this.passwordHash = hash;
}
setPlayerState(playerState: PlayerState, playerId: string): void {
this.partyState[playerId] = playerState;
}
deletePlayer(playerId: string): void {
delete this.partyState[playerId];
}
deleteGameData(): void {
this.state = {};
this.partyState = {};
}
setState(field: "map" | "mapState" | "manifest", value: any): void {
if (!value) {
delete this.state[field];
}
this.state[field] = value;
}
getState(field: "map" | "mapState" | "manifest"): MapState | Manifest | Map {
const result = this.state[field];
return result;
}
}

View File

@ -0,0 +1,87 @@
import { PlayerState } from "../types/PlayerState";
import { PartyState } from "../types/PartyState";
import { MapState } from "../types/MapState";
import { Manifest } from "../types/Manifest";
import { Map } from "../types/Map";
import Game from "./Game";
export default class GameRepository {
games: Record<string, Game>;
constructor() {
this.games = {};
}
setGameCreation(gameId: string, hash: string): void {
const game = new Game(gameId, hash);
this.games[gameId] = game;
}
isGameCreated(gameId: string): boolean {
if (this.games[gameId] === undefined) {
return false;
}
return true;
}
getPartyState(gameId: string): PartyState {
const game = this.games[gameId];
const result = game.getPartyState();
return result;
}
getGamePasswordHash(gameId: string): string {
const game = this.games[gameId];
const result = game.getGamePasswordHash();
return result;
}
setGamePasswordHash(gameId: string, hash: string): void {
const game = this.games[gameId];
game.setGamePasswordHash(hash);
}
setPlayerState(
gameId: string,
playerState: PlayerState,
playerId: string
): void {
const game = this.games[gameId];
game.setPlayerState(playerState, playerId);
}
deletePlayer(gameId: string, playerId: string): void {
const game = this.games[gameId];
game.deletePlayer(playerId);
}
deleteGameData(gameId: string): void {
const game = this.games[gameId];
game.deleteGameData();
}
setState(
gameId: string,
field: "map" | "mapState" | "manifest",
value: any
): void {
const game = this.games[gameId];
game.setState(field, value);
}
getState(
gameId: string,
field: "map" | "mapState" | "manifest"
): MapState | Manifest | Map {
const game = this.games[gameId];
const result = game.getState(field);
return result;
}
}

View File

@ -0,0 +1,260 @@
/* eslint-disable no-underscore-dangle */
import { Server as HttpServer } from "http";
import { Socket, Server as IOServer } from "socket.io";
import Auth from "./Auth";
import GameRepository from "./GameRepository";
import GameState from "./GameState";
import { Update } from "../helpers/diff";
import { Map } from "../types/Map";
import { MapState } from "../types/MapState";
import { PlayerState } from "../types/PlayerState";
import { Manifest } from "../types/Manifest";
import { Pointer } from "../types/Pointer";
export default class GameServer {
private readonly io: IOServer;
private gameRepo;
constructor(io: IOServer) {
this.io = io;
this.gameRepo = new GameRepository();
}
public initaliseSocketServer(httpServer: HttpServer) {
this.io.listen(httpServer);
}
public run(): void {
this.io.on("connect", async (socket: Socket) => {
const gameState = new GameState(this.io, socket, this.gameRepo);
let _gameId: string;
socket.on("signal", (data: string) => {
try {
const { to, signal } = JSON.parse(data);
this.io.to(to).emit("signal", { from: socket.id, signal });
} catch (error) {
console.error("SIGNAL_ERROR", error);
}
});
socket.on("disconnecting", async () => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
socket.to(gameId).emit("player_left", socket.id);
// Delete player state from game
this.gameRepo.deletePlayer(gameId, socket.id);
// Update party state
const partyState = this.gameRepo.getPartyState(gameId);
socket.to(gameId).emit("party_state", partyState);
} catch (error) {
console.error("DISCONNECT_ERROR", error);
}
});
socket.on("join_game", async (gameId: string, password: string) => {
const auth = new Auth();
_gameId = gameId;
try {
if (typeof gameId !== "string" || typeof password !== "string") {
console.log("invalid type in party credentials");
socket.emit("auth_error");
return;
}
const created = this.gameRepo.isGameCreated(gameId);
if (!created) {
// Create a game and join
const hash = await auth.createPasswordHash(password);
this.gameRepo.setGameCreation(gameId, hash);
await gameState.joinGame(gameId);
} else {
// Join existing game
const hash = this.gameRepo.getGamePasswordHash(gameId);
const res = await auth.checkPassword(password, hash);
if (res) {
await gameState.joinGame(gameId);
} else {
socket.emit("auth_error");
}
}
this.io.to(gameId).emit("joined_game", socket.id);
} catch (error) {
console.error("JOIN_ERROR", error);
}
});
socket.on("map", async (map: Map) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
this.gameRepo.setState(gameId, "map", map);
const state = this.gameRepo.getState(gameId, "map");
socket.broadcast.to(gameId).emit("map", state);
} catch (error) {
console.error("MAP_ERROR", error);
}
});
socket.on("map_state", async (mapState: MapState) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
this.gameRepo.setState(gameId, "mapState", mapState);
const state = this.gameRepo.getState(gameId, "mapState");
socket.broadcast.to(gameId).emit("map_state", state);
} catch (error) {
console.error("MAP_STATE_ERROR", error);
}
});
socket.on("map_state_update", async (update: Update<MapState>) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
if (await gameState.updateState(gameId, "mapState", update)) {
socket.to(gameId).emit("map_state_update", update);
}
} catch (error) {
console.error("MAP_STATE_UPDATE_ERROR", error);
}
});
socket.on("player_state", async (playerState: PlayerState) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
this.gameRepo.setPlayerState(gameId, playerState, socket.id);
await gameState.broadcastPlayerState(gameId, socket, "party_state");
} catch (error) {
console.error("PLAYER_STATE_ERROR", error);
}
});
socket.on("manifest", async (manifest: Manifest) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
this.gameRepo.setState(gameId, "manifest", manifest);
const state = this.gameRepo.getState(gameId, "manifest");
socket.broadcast.to(gameId).emit("manifest", state);
} catch (error) {
console.error("MANIFEST_ERROR", error);
}
});
socket.on("manifest_update", async (update: Update<Manifest>) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
if (await gameState.updateState(gameId, "manifest", update)) {
socket.to(gameId).emit("manifest_update", update);
}
} catch (error) {
console.error("MANIFEST_UPDATE_ERROR", error);
}
});
socket.on("player_pointer", async (playerPointer: Pointer) => {
try {
let gameId: string;
if (_gameId) {
gameId = _gameId;
} else {
const result = gameState.getGameId();
if (result) {
gameId = result;
_gameId = result;
} else {
return;
}
}
socket.to(gameId).emit("player_pointer", playerPointer);
} catch (error) {
console.error("POINTER_ERROR", error);
}
});
});
}
}

View File

@ -0,0 +1,67 @@
import { Server, Socket } from "socket.io";
import { applyChanges, Update } from "../helpers/diff";
import GameRepository from "./GameRepository";
export default class GameState {
io: Server;
socket: Socket;
gameRepository: GameRepository;
constructor(io: Server, socket: Socket, gameRepository: GameRepository) {
this.io = io;
this.socket = socket;
this.gameRepository = gameRepository;
}
getGameId(): string | undefined {
let gameId;
// eslint-disable-next-line no-restricted-syntax
for (const room of this.socket.rooms) {
if (room !== this.socket.id) {
gameId = room;
}
}
return gameId;
}
async joinGame(gameId: string): Promise<void> {
await this.socket.join(gameId);
const partyState = this.gameRepository.getPartyState(gameId);
this.socket.emit("party_state", partyState);
const mapState = this.gameRepository.getState(gameId, "mapState");
this.socket.emit("map_state", mapState);
const map = this.gameRepository.getState(gameId, "map");
this.socket.emit("map", map);
const manifest = this.gameRepository.getState(gameId, "manifest");
this.socket.emit("manifest", manifest);
this.socket.to(gameId).emit("player_joined", this.socket.id);
}
async broadcastPlayerState(
gameId: string,
socket: Socket,
eventName: string
): Promise<void> {
const state = this.gameRepository.getPartyState(gameId);
socket.broadcast.to(gameId).emit(eventName, state);
}
async updateState(
gameId: string,
field: "map" | "mapState" | "manifest",
update: Update<any>
): Promise<boolean> {
const state = this.gameRepository.getState(gameId, field) as any;
if (state && !(state instanceof Map) && update.id === state["mapId"]) {
applyChanges(state, update.changes);
this.gameRepository.setState(gameId, field, state);
return true;
}
return false;
}
}

View File

@ -0,0 +1,12 @@
export default class Global {
static ORIGIN_WHITELIST: string = process.env.ALLOW_ORIGIN!!;
static CONNECTION_PORT: string | number = process.env.PORT || 9000;
static ICE_SERVERS: string = `
{
"iceServers": [
]
}
`;
}

View File

@ -0,0 +1,9 @@
import Global from "./Global";
export default class IceServer {
async getIceServers(): Promise<any> {
const servers = Global.ICE_SERVERS;
const data = JSON.parse(servers);
return data;
}
}

View File

@ -0,0 +1,22 @@
import diff, { Diff } from "deep-diff";
import get from "lodash.get";
const { applyChange } = diff;
export function applyChanges<LHS>(target: LHS, changes: Diff<LHS, any>[]) {
for (let change of changes) {
if (change.path && (change.kind === "E" || change.kind === "A")) {
// If editing an object or array ensure that the value exists
const valid = get(target, change.path) !== undefined;
if (valid) {
applyChange(target, true, change);
}
} else {
applyChange(target, true, change);
}
}
}
export type Update<T> = {
id: string;
changes: Diff<T>[];
};

79
backend/src/index.ts Normal file
View File

@ -0,0 +1,79 @@
import cors, { CorsOptions } from "cors";
import express, { Application, RequestHandler } from "express";
import helmet from "helmet";
import { Server } from "socket.io";
// @ts-ignore
import msgParser from "socket.io-msgpack-parser";
import AppServer from "./entities/AppServer";
import Controller from "./controllers/Controller";
import GameServer from "./entities/GameServer";
import Global from "./entities/Global";
import HealthcheckController from "./controllers/HealthcheckController";
import IceServer from "./entities/IceServer";
import IceServerController from "./controllers/IceServerController";
const app: Application = express();
const port: string | number = Global.CONNECTION_PORT;
const server = new AppServer(app, port);
const whitelist = new RegExp(Global.ORIGIN_WHITELIST);
const io = new Server(server, {
cookie: false,
cors: {
origin: whitelist,
methods: ["GET", "POST"],
credentials: true,
},
serveClient: false,
maxHttpBufferSize: 1e7,
parser: msgParser,
});
const corsConfig: CorsOptions = {
origin: function (origin: any, callback: any) {
if (!origin || whitelist.test(origin)) {
return callback(null, true);
}
const msg =
"The CORS policy for this site does not allow access from the specified Origin.";
return callback(new Error(msg), false);
},
};
const iceServer = new IceServer();
const globalMiddleware: Array<RequestHandler> = [helmet(), cors(corsConfig)];
const controllers: Array<Controller> = [
new HealthcheckController(),
new IceServerController(iceServer),
];
server.loadMiddleware(globalMiddleware);
server.loadControllers(controllers);
const httpServer = server.run();
const game = new GameServer(io);
game.initaliseSocketServer(httpServer);
game.run();
process.once("SIGTERM", () => {
console.log("sigterm event");
server.close(() => {
console.log("http server closed");
});
io.close(() => {
console.log("socket server closed");
io.sockets.emit("server shutdown");
});
setTimeout(() => {
process.exit(0);
}, 3000).unref();
});
process.on("unhandledRejection", (reason, p) => {
console.log("Unhandled Rejection at: Promise", p, "reason:", reason);
// application specific logging, throwing an error, or other logic here
});

View File

@ -0,0 +1,15 @@
export type Color = {
blue: string;
orange: string;
red: string;
yellow: string;
purple: string;
green: string;
pink: string;
teal: string;
black: string;
darkGray: string;
lightGray: string;
white: string;
primary: string;
}

13
backend/src/types/Dice.ts Normal file
View File

@ -0,0 +1,13 @@
export type DiceType = "d4" | "d6" | "d8" | "d10" | "d12" | "d20" | "d100";
export type DiceRoll = {
type: DiceType;
roll: number | "unknown";
};
export type Dice = {
share: boolean;
rolls: DiceRoll[];
};

View File

@ -0,0 +1,70 @@
import { Vector2 } from "./Vector2";
import { Color } from "./Color";
export type PointsData = {
points: Vector2[];
};
export type RectData = {
x: number;
y: number;
width: number;
height: number;
};
export type CircleData = {
x: number;
y: number;
radius: number;
};
export type ShapeData = PointsData | RectData | CircleData;
export type BaseDrawing = {
blend: boolean;
color: Color;
id: string;
strokeWidth: number;
};
export type BaseShape = BaseDrawing & {
type: "shape";
};
export type Line = BaseShape & {
shapeType: "line";
data: PointsData;
};
export type Rectangle = BaseShape & {
shapeType: "rectangle";
data: RectData;
};
export type Circle = BaseShape & {
shapeType: "circle";
data: CircleData;
};
export type Triangle = BaseShape & {
shapeType: "triangle";
data: PointsData;
};
export type ShapeType =
| Line["shapeType"]
| Rectangle["shapeType"]
| Circle["shapeType"]
| Triangle["shapeType"];
export type Shape = Line | Rectangle | Circle | Triangle;
export type Path = BaseDrawing & {
type: "path";
pathType: "fill" | "stroke";
data: PointsData;
};
export type Drawing = Shape | Path;
export type DrawingState = Record<string, Drawing>;

18
backend/src/types/Fog.ts Normal file
View File

@ -0,0 +1,18 @@
import { Vector2 } from "./Vector2";
import { Color } from "./Color";
export type FogData = {
points: Vector2[];
holes: Vector2[][];
};
export type Fog = {
color: Color;
data: FogData;
id: string;
strokeWidth: number;
type: "fog";
visible: boolean;
};
export type FogState = Record<string, Fog>;

39
backend/src/types/Grid.ts Normal file
View File

@ -0,0 +1,39 @@
import { Vector2 } from "./Vector2";
export type GridInset = {
/** Top left position of the inset */
topLeft: Vector2;
/** Bottom right position of the inset */
bottomRight: Vector2;
};
export type GridMeasurementType =
| "chebyshev"
| "alternating"
| "euclidean"
| "manhattan";
export type GridMeasurement = {
type: GridMeasurementType;
scale: string;
};
export type GridType = "square" | "hexVertical" | "hexHorizontal";
export type Grid = {
/** The inset of the grid from the map */
inset: GridInset;
/** The number of columns and rows of the grid as `x` and `y` */
size: Vector2;
type: GridType;
measurement: GridMeasurement;
};
export type GridScale = {
/** The number multiplier of the scale */
multiplier: number;
/** The unit of the scale */
unit: string;
/** The precision of the scale */
digits: number;
};

View File

@ -0,0 +1,6 @@
export type ManifestAsset = {
id: string;
owner: string;
};
export type ManifestAssets = Record<string, ManifestAsset>;
export type Manifest = { mapId: string; assets: ManifestAssets };

38
backend/src/types/Map.ts Normal file
View File

@ -0,0 +1,38 @@
import { Grid } from "./Grid";
export type BaseMap = {
id: string;
name: string;
owner: string;
grid: Grid;
width: number;
height: number;
lastModified: number;
created: number;
showGrid: boolean;
snapToGrid: boolean;
};
export type DefaultMap = BaseMap & {
type: "default";
key: string;
};
export type FileMapResolutions = {
low?: string;
medium?: string;
high?: string;
ultra?: string;
};
export type MapQuality = keyof FileMapResolutions | "original";
export type FileMap = BaseMap & {
type: "file";
file: string;
resolutions: FileMapResolutions;
thumbnail: string;
quality: MapQuality;
};
export type Map = DefaultMap | FileMap;

View File

@ -0,0 +1,15 @@
import { DrawingState } from "./Drawing";
import { FogState } from "./Fog";
import { Notes } from "./Note";
import { TokenStates } from "./TokenState";
export type EditFlag = "drawing" | "tokens" | "notes" | "fog";
export type MapState = {
tokens: TokenStates;
drawings: DrawingState;
fogs: FogState;
editFlags: Array<EditFlag>;
notes: Notes;
mapId: string;
};

18
backend/src/types/Note.ts Normal file
View File

@ -0,0 +1,18 @@
import { Color } from "./Color";
export type Note = {
id: string;
color: Color;
lastModified: number;
lastModifiedBy: string;
locked: boolean;
size: number;
text: string;
textOnly: boolean;
visible: boolean;
x: number;
y: number;
rotation: number;
};
export type Notes = Record<string, Note>;

View File

@ -0,0 +1,21 @@
export type CircleOutline = {
type: "circle";
x: number;
y: number;
radius: number;
};
export type RectOutline = {
type: "rect";
width: number;
height: number;
x: number;
y: number;
};
export type PathOutline = {
type: "path";
points: number[];
};
export type Outline = CircleOutline | RectOutline | PathOutline;

View File

@ -0,0 +1,3 @@
import { PlayerState } from "./PlayerState";
export type PartyState = Record<string, PlayerState>;

View File

@ -0,0 +1,10 @@
import { Timer } from "./Timer";
import { Dice } from "./Dice";
export type PlayerState = {
nickname: string;
timer?: Timer;
dice: Dice;
sessionId?: string;
userId?: string;
};

View File

@ -0,0 +1,10 @@
import { Vector2 } from "./Vector2";
import { Color } from "./Color";
export type Pointer = {
position: Vector2;
visible: boolean;
id: string;
color: Color;
time: number;
};

View File

@ -0,0 +1,4 @@
export type Timer = {
current: number;
max: number;
};

View File

@ -0,0 +1,31 @@
import { Outline } from "./Outline";
export type TokenCategory = "character" | "vehicle" | "prop" | "attachment";
export type BaseToken = {
id: string;
name: string;
defaultSize: number;
defaultCategory: TokenCategory;
defaultLabel: string;
hideInSidebar: boolean;
width: number;
height: number;
owner: string;
created: number;
lastModified: number;
outline: Outline;
};
export type DefaultToken = BaseToken & {
type: "default";
key: string;
};
export type FileToken = BaseToken & {
type: "file";
file: string;
thumbnail: string;
};
export type Token = DefaultToken | FileToken;

View File

@ -0,0 +1,37 @@
import { Color } from "./Color";
import { Outline } from "./Outline";
import { TokenCategory } from "./Token";
export type BaseTokenState = {
id: string;
tokenId: string;
owner: string;
size: number;
category: TokenCategory;
label: string;
statuses: Color[];
x: number;
y: number;
lastModifiedBy: string;
lastModified: number;
rotation: number;
locked: boolean;
visible: boolean;
outline: Outline;
width: number;
height: number;
};
export type DefaultTokenState = BaseTokenState & {
type: "default";
key: string;
};
export type FileTokenState = BaseTokenState & {
type: "file";
file: string;
};
export type TokenState = DefaultTokenState | FileTokenState;
export type TokenStates = Record<string, TokenState>;

View File

@ -0,0 +1,4 @@
export type Vector2 = {
x: number;
y: number;
}

17
backend/tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"outDir": "./build",
"allowJs": true,
"allowSyntheticDefaultImports": true,
"target": "es6",
"noFallthroughCasesInSwitch": true,
"noEmitOnError": true,
"pretty": true,
"moduleResolution": "node",
"strict": true,
"sourceMap": true,
"skipLibCheck": true
},
"include": ["./src/**/*"],
"exclude": ["node_modules/**/*", "./build/**/*"]
}

2271
backend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff