38 Commits

Author SHA1 Message Date
Mitchell McCaffrey
bf0bef09b1 Update README.md 2023-07-18 07:27:13 +10:00
Mitchell McCaffrey
d0175180f1 Update README.md 2023-07-18 07:24:32 +10:00
nthouliss
9b8c26b75a Edit backend scripts 2023-06-17 11:14:53 +10:00
Mitchell McCaffrey
748f7e5d28 Update README.md 2023-06-16 15:36:36 +10:00
nthouliss
eeeb0f7126 Edit formatting in readme 2023-06-16 14:26:04 +10:00
nthouliss
d6cc5517ef Edit branch in render.yaml 2023-06-16 14:01:47 +10:00
nthouliss
829d27f8be Edit readme 2023-06-16 13:34:44 +10:00
nthouliss
df513b9a2e Edit auto-close pull request message 2023-06-16 13:07:46 +10:00
nthouliss
d26adc42f2 Add pull-request actionto workflows folder 2023-06-16 13:03:23 +10:00
nthouliss
b294a31769 Add action to auto-close pull requests 2023-06-16 13:02:23 +10:00
Mitchell McCaffrey
ac08f3c7a0 Update README.md 2023-06-16 13:00:14 +10:00
Mitchell McCaffrey
11f96aee1a Merge pull request #65 from owlbear-rodeo/feature/open-source
Feature/open source
2023-06-16 12:41:37 +10:00
Mitchell McCaffrey
2d4d06aa33 Remove unused env variables 2023-06-16 12:31:09 +10:00
nthouliss
5adcf6e5c0 Revert "Edit env var to take manual input on render"
This reverts commit bffa432b29.
2023-06-15 16:49:40 +10:00
nthouliss
bffa432b29 Edit env var to take manual input on render 2023-06-15 16:48:22 +10:00
nthouliss
1b288b5f31 Edit env var on backend 2023-06-15 16:45:55 +10:00
nthouliss
7a1ed6007a Edit directory config on backend in render.yaml 2023-06-15 16:41:54 +10:00
nthouliss
68826aa1d0 Edit env vars 2023-06-15 16:38:58 +10:00
nthouliss
d5312fd4fd Edit format on branch in render.yaml 2023-06-15 16:21:09 +10:00
nthouliss
a5ebab97ac Edit rootDir in render.yaml 2023-06-15 16:19:54 +10:00
nthouliss
d6e204476a Edit service name in render.yaml 2023-06-15 16:17:49 +10:00
nthouliss
846be9c3ec Edit render.yaml 2023-06-15 16:17:13 +10:00
nthouliss
0062a2cabd Add branch to render.yaml 2023-06-15 16:14:59 +10:00
Mitchell McCaffrey
a9848d43e7 Update title 2023-06-15 16:09:41 +10:00
Mitchell McCaffrey
7e4b135a23 Remove unused comments 2023-06-15 16:09:31 +10:00
nthouliss
7b3e778ea5 Create render.yaml 2023-06-15 16:08:34 +10:00
nthouliss
3507d14a29 Edit configuration - use docker compose to run frontend and backend 2023-06-15 16:06:29 +10:00
Mitchell McCaffrey
e4682a042d Update README.md 2023-06-15 15:56:17 +10:00
Mitchell McCaffrey
2db2542e5a Update README 2023-06-15 14:29:51 +10:00
Mitchell McCaffrey
7583dcb80a Update home page 2023-06-15 12:01:17 +10:00
Mitchell McCaffrey
d26d938df3 Update FAQ 2023-06-15 12:01:10 +10:00
Mitchell McCaffrey
f8e9c43a2a Remove migration notification 2023-06-15 12:01:02 +10:00
Mitchell McCaffrey
37f59cd896 Remove unused env variables 2023-06-15 12:00:52 +10:00
Mitchell McCaffrey
b62099a655 Remove donate page 2023-06-15 12:00:27 +10:00
Mitchell McCaffrey
812ca7eab3 Merge branch 'open-source' into feature/open-source 2023-06-15 11:32:19 +10:00
Mitchell McCaffrey
d09bffaa96 Add new CC0 tokens 2023-06-14 18:18:43 +10:00
nthouliss
4716126cc8 Remove fathom 2023-01-16 12:02:45 +11:00
nthouliss
163d11432e Add backend to repository 2023-01-16 12:02:32 +11:00
92 changed files with 3990 additions and 314 deletions

5
.dockerignore Normal file
View File

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

6
.env
View File

@@ -1,9 +1,3 @@
REACT_APP_BROKER_URL=http://localhost:9000
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
REACT_APP_STRIPE_API_KEY=pk_test_8M3NHrF1eI2b84ubF4F8rSTe0095R3f0My
REACT_APP_STRIPE_URL=http://localhost:9000
REACT_APP_VERSION=$npm_package_version
REACT_APP_PREVIEW=false
REACT_APP_LOGGING=false
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
REACT_APP_MAINTENANCE=false

View File

@@ -1,10 +1,2 @@
REACT_APP_BROKER_URL=https://rocket.owlbear.rodeo
REACT_APP_ICE_SERVERS_URL=https://rocket.owlbear.rodeo/iceservers
REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51
REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo
REACT_APP_VERSION=$npm_package_version
REACT_APP_PREVIEW=false
REACT_APP_LOGGING=false
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
REACT_APP_SENTRY_DSN=https://d6d22c5233b54c4d91df8fa29d5ffeb0@o467475.ingest.sentry.io/5493956
REACT_APP_MAINTENANCE=false

13
.github/workflows/pull-requests.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
name: Close Pull Request
on:
pull_request_target:
types: [opened]
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: "Sorry but this repository is not open for contribution. We do not accept pull requests."

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# DEVELOPMENT DOCKERFILE ONLY
# THIS DOCKERFILE IS NOT INTENDED FOR PRODUCTION. IT LEVERAGES DEVELOPMENT DEPENDENCIES TO RUN.
FROM node:16.20.0-alpine3.18 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
FROM node:16.20.0-alpine3.18
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 tsconfig.json ./
COPY --chown=node:node ./src/ ./src
COPY --chown=node:node ./public/ ./public
COPY --chown=node:node --from=builder /home/node/app/node_modules ./node_modules
CMD ["yarn", "start"]

127
README.md
View File

@@ -1,7 +1,126 @@
# Owlbear Rodeo
# Owlbear Rodeo Legacy
Tooling:
- [Peer JS](https://peerjs.com/docs.html)
- [Theme UI](https://theme-ui.com/)
![Demo Image](/demo.jpg)
This is the source code for Owlbear Rodeo 1.0 released for your non-profit, non-commercial, private use.
This code won't be maintained or supported by us as we've moved all our time to maintaining and building the new Owlbear Rodeo.
## Background
Owlbear Rodeo was created as a passion project in early 2020.
We worked on the site from that time until late 2021, spending nights and weekends learning and building the site found in this repo.
By the end of 2021 we were spending more time working on Owlbear Rodeo than we were at our day jobs. After almost two years working on the project we had learnt a lot about what we wanted Owlbear Rodeo to be. With these two things in mind we decided to start working on a new version of Owlbear Rodeo, throwing out the old version and starting fresh.
This new version would streamline common actions, be able to grow with the user and be a lot more reliable. We called this version Owlbear Rodeo 2.0 and it is what you see now when opening [Owlbear Rodeo](https://owlbear.app) today.
Owlbear Rodeo 2.0 was released mid 2023 but as a thanks to our community we wanted to give you the option to build and run the original version.
## Programming Notes
This project marks one of the first big web projects Nicola and I made. With this in mind there are many things that aren't great with it.
The state management for the frontend relies primarily on React contexts. This is a bad idea. React contexts are great but not for a performance focused app like this. You'll see that every context that is used has to be split into a bunch of small chunks to prevent unnecessary re-renders. Don't do this. Just use a state management library like Zustand. It's so much easier and it's what we do in 2.0.
This code makes no effort to handle collisions when two users edit the same data at the same time. This means that it can be pretty easy to brick a map with a combination of delete/edit/undo between two users. Instead you should use a data structure that is designed to handle this like a CRDT. This is what we do in 2.0.
All images are stored client side in an IndexedDB database. I think this is a cool idea but browsers hate it when you do this. There are ways to tell the browser that you should keep this data around (the persistent storage API). But it's a bit of a mess. Chrome will silently decide if it wants to keep the data, FireFox will prompt the user for permission and Safari will ignore you. In fact if you don't visit the site every week Safari will delete all your data.
I thought I was being pretty clever when I created 1.0 with no cloud storage but it bit us hard having to tell users every week that there was no way to recover their data because the browser was cranky at them.
Because this project doesn't have user accounts or cloud storage to share an image with other players we use a peer-to-peer connection. To do this we use WebRTC. Be warned though every time I've decided to use WebRTC in a project I've regretted it. WebRTC is great on paper but it will never be reliable. Many VPNs/Antivirus/ISPs will block all WebRTC traffic. So you end up having to proxy a bunch of traffic through TURN/STUN servers which end up eating up any cost savings you thought you were going to have.
There are some pretty cool things in the project though.
I developed a Tensorflow model trained off of thousands of battle maps that can detect the number of grid cells visible in a map. The model is in the `src/ml` directory.
The 3D dice roller is physics driven which makes it fun to just throw dice around.
The pointer tool has a nice network interpolation model. This idea was expanded in 2.0 so that every interaction you do is synced in real-time with other players.
## Install
### Production (or for non-developers)
The easiest way to host Owlbear Rodeo is by using a cloud provider.
To make this even easier we have provided a blueprint that will allow you to host it on [Render](https://render.com/).
Clicking the button bellow will open the Render website where you can create an account and setup a server for free.
Once deployed Render will provide a URL for you to share with your players.
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy)
### Locally
#### **Docker**
To use the project with docker you can run the following from the root directory of the project:
```
docker-compose up
```
Note: You will need to increase your max memory allowed in Docker to 5GB in order to build Owlbear Rodeo.
#### **Manual**
If you don't want to use Docker you can run both the backend and frontend manually.
Before running the commands you will need both `NodeJS v16` and `Yarn` installed.
To build and run the backend in `/backend` run:
```
yarn build
```
and:
```
yarn start
```
To run the frontend in the root folder run:
```
yarn start
```
## Troubleshooting
**Custom Images Aren't Showing on Other Computers**
Custom images are transferred using WebRTC in order to navigate some networks you must define a STUN/TURN server.
You can read more about this topic here https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Protocols
While there are some freely available STUN servers, most TURN servers charge you for the bandwidth used.
To change the STUN/TURN configuration you can edit the `/backend/ice.json` file.
---
## License
This project is for **personal** use only.
We do not offer a license for commercial use.
You may not modify, publish, transmit, participate in the transfer or sale of, reproduce, create new works from, display, communicate to the public or in any way exploit, any part of this project in any way for commercial purposes.
If you're interested in using Owlbear Rodeo for commercial purposes you can contact us about version 2.0 here: contact@owlbear.rodeo
## Contributing
The code provided here is for historical purposes and as such we have disabled pull requests on this repository.
## Credits
This project was created by [Nicola](https://github.com/nthouliss) and [Mitch](https://github.com/mitchemmc).
In the live version of Owlbear Rodeo we provide default tokens that use licensed images.
To ensure that the license is adhered to we have replaced those images with CC0 images.
Here are a list of the image libraries used to make these new tokens:
- [496 pixel art icons for medieval/fantasy RPG](https://opengameart.org/content/496-pixel-art-icons-for-medievalfantasy-rpg)
- [CC0 Music Icons](https://opengameart.org/content/cc0-music-icons)
- [Dungeon Crawl 32x32 tiles supplemental](https://opengameart.org/content/dungeon-crawl-32x32-tiles-supplemental)
- [Zombie and Skeleton 32x48](https://opengameart.org/content/zombie-and-skeleton-32x48)
- [RPG portraits](https://opengameart.org/content/rpg-portraits)

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/**

31
backend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:16.20.0-alpine3.18 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:16.20.0-alpine3.18
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 ice.json ./
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"]

7
backend/README.md Normal file
View File

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

3
backend/ice.json Normal file
View File

@@ -0,0 +1,3 @@
{
"iceServers": [{ "urls": "stun:stun.l.google.com:19302" }]
}

39
backend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"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/src/index.js",
"start": "node --es-module-specifier-resolution=node ./build/src/index.js",
"lint": "eslint src/**/*.ts"
},
"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,37 @@
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) {
console.error(JSON.stringify(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,60 @@
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 = this.partyState;
return result;
}
getGamePasswordHash(): string {
const result = this.passwordHash;
return result;
}
setGamePasswordHash(hash: string): void {
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,21 @@
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default class Global {
static ORIGIN_WHITELIST: string = process.env.ALLOW_ORIGIN!!;
static CONNECTION_PORT: string | number = process.env.PORT || 9000;
static ICE_SERVERS = fs
.readFile(path.resolve(__dirname, "../../", "ice.json"), "utf8")
.then((data) => {
return JSON.parse(data);
})
.catch((error) => {
throw new Error(error);
});
}

View File

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

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;
}

19
backend/tsconfig.json Normal file
View File

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

2271
backend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
demo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
backend:
build: ./backend/
stop_grace_period: 30s
init: true
ports:
- 9000:9000
environment:
PORT: 9000
ALLOW_ORIGIN: ".*"
frontend:
build:
context: ./
depends_on:
- backend
ports:
- 3000:3000
environment:
REACT_APP_BROKER_URL: "http://localhost:9000"
REACT_APP_VERSION: "1.10.2"
REACT_APP_MAINTENANCE: "false"

View File

@@ -10,7 +10,6 @@
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.8.0",
"@react-spring/konva": "9.4.4",
"@stripe/stripe-js": "^1.44.1",
"@tensorflow/tfjs": "^3.15.0",
"@testing-library/jest-dom": "^5.16.3",
"@testing-library/react": "^13.0.0",

View File

@@ -5,10 +5,7 @@
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#222639" />
<meta
name="description"
content="Tabletop map sharing ʕノ•ᴥ•ʔノ┬─┬ヽʕ•ᴥ•ヽʔ"
/>
<meta name="description" content="Tabletop map sharing but vintage" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -24,12 +21,9 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Owlbear Rodeo</title>
<title>Owlbear Rodeo Legacy Edition</title>
<meta property="og:image" content="%PUBLIC_URL%/thumbnail.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<!-- Fathom -->
<script src="https://angelfish.owlbear.rodeo/script.js" data-spa="auto" data-site="%REACT_APP_FATHOM_SITE_ID%" data-excluded-domains="localhost" defer></script>
<!-- / Fathom -->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

54
render.yaml Normal file
View File

@@ -0,0 +1,54 @@
services:
- type: web
name: Owlbear Rodeo Legacy
runtime: static
buildCommand: yarn build
staticPublishPath: ./build
pullRequestPreviewsEnabled: false
branch: main
buildFilter:
paths:
- src/**/*.js
- src/**/*.ts
ignoredPaths:
- src/**/*.test.js
routes:
- type: rewrite
source: /*
destination: /index.html
headers:
- path: /*
name: X-Frame-Options
value: sameorigin
- path: /*
name: X-Robots-Tag
value: noindex
envVars:
- key: REACT_APP_BROKER_URL
fromService:
type: web
name: "Owlbear Rodeo Backend"
envVarKey: RENDER_EXTERNAL_URL
- key: REACT_APP_MAINTENANCE
value: false
- key: REACT_APP_VERSION
value: "1.10.2"
# A Docker web service
- type: web
name: Owlbear Rodeo Backend
runtime: docker
region: oregon # optional (defaults to oregon) check other regions here: https://render.com/docs/regions
plan: free
branch: main
rootDir: ./backend
dockerfilePath: ./Dockerfile
numInstances: 1
healthCheckPath: /health
envVars:
- key: ORIGIN_WHITELIST
fromService:
type: web
name: "Owlbear Rodeo Legacy"
envVarKey: RENDER_EXTERNAL_HOSTNAME
- key: PORT
value: 9000

View File

@@ -1,12 +1,59 @@
import { ThemeProvider } from "theme-ui";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import theme from "./theme";
import MigrationModal from "./modals/MigrationModal";
import Home from "./routes/Home";
import Game from "./routes/Game";
import About from "./routes/About";
import FAQ from "./routes/FAQ";
import ReleaseNotes from "./routes/ReleaseNotes";
import HowTo from "./routes/HowTo";
import { AuthProvider } from "./contexts/AuthContext";
import { SettingsProvider } from "./contexts/SettingsContext";
import { KeyboardProvider } from "./contexts/KeyboardContext";
import { DatabaseProvider } from "./contexts/DatabaseContext";
import { UserIdProvider } from "./contexts/UserIdContext";
import { ToastProvider } from "./components/Toast";
function App() {
return (
<ThemeProvider theme={theme}>
<MigrationModal />
<SettingsProvider>
<AuthProvider>
<KeyboardProvider>
<ToastProvider>
<Router>
<Switch>
<Route path="/how-to">
<HowTo />
</Route>
<Route path="/release-notes">
<ReleaseNotes />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/faq">
<FAQ />
</Route>
<Route path="/game/:id">
<DatabaseProvider>
<UserIdProvider>
<Game />
</UserIdProvider>
</DatabaseProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
</ToastProvider>
</KeyboardProvider>
</AuthProvider>
</SettingsProvider>
</ThemeProvider>
);
}

View File

@@ -1,40 +0,0 @@
import { useState } from "react";
import { Box, Close, Link, Text } from "theme-ui";
export function MigrationNotification() {
const [closed, setClosed] = useState(false);
if (closed) {
return null;
}
return (
<Box
sx={{ position: "fixed", bottom: 0, left: 0, right: 0, zIndex: 1000 }}
bg="highlight"
>
<Box
m={2}
mb={0}
sx={{
borderRadius: "4px",
padding: "12px 16px",
display: "flex",
}}
>
<Text as="p" variant="body2" sx={{ flexGrow: 1, textAlign: "center" }}>
The new era of Owlbear Rodeo is coming on July 18th. Make sure to
migrate your data before July 18th.{" "}
<Link
href="https://blog.owlbear.rodeo/owlbear-rodeo-2-0-release-date-announcement/"
target="_blank"
rel="noopener noreferrer"
>
Read more
</Link>
</Text>
<Close onClick={() => setClosed(true)} sx={{ minWidth: "32px" }} />
</Box>
</Box>
);
}

View File

@@ -5,15 +5,3 @@
### Can my stream use Owlbear Rodeo?
Sure!
### Can I self-host Owlbear Rodeo?
At this time we have no plans to offer a self hosted version of Owlbear Rodeo.
### Is Owlbear Rodeo open source?
Owlbear Rodeo is not open source at the moment.
### Can I contribute code to Owlbear Rodeo?
At the moment we are not taking code contributions to Owlbear Rodeo.

View File

@@ -1,47 +0,0 @@
import { Box, Button, Container, Text } from "theme-ui";
function MigrationModal() {
return (
<Container>
<Box>
<Text py={2} sx={{ textAlign: "center", mc: "auto" }}>
<h1>Owlbear Rodeo 2.0 is coming!</h1>
</Text>
<img
src="/nestling.png"
alt="nestling"
style={{ width: 200, margin: "0 auto", display: "block" }}
/>
<Text
as="p"
variant="body"
sx={{ flexGrow: 1, textAlign: "center", mt: 3 }}
>
Migration is now taking place
</Text>
<Button
//@ts-ignore
href="https://blog.owlbear.rodeo/owlbear-rodeo-2-0-release-date-announcement/"
target="_blank"
rel="noopener noreferrer"
as="a"
variant="primary"
sx={{
backgroundColor: "hsl(260, 100%, 80%)",
color: "black",
border: "none",
alignContent: "center",
width: "50%",
mx: "auto",
display: "block",
mt: 4,
}}
>
Read more
</Button>
</Box>
</Container>
);
}
export default MigrationModal;

View File

@@ -64,7 +64,7 @@ class Session extends EventEmitter {
async connect() {
try {
if (
!process.env.REACT_APP_ICE_SERVERS_URL ||
!process.env.REACT_APP_BROKER_URL ||
process.env.REACT_APP_MAINTENANCE === "true"
) {
this.emit("status", "offline");
@@ -75,7 +75,9 @@ class Session extends EventEmitter {
parser: msgParser,
transports: ["websocket"],
});
const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL);
const response = await fetch(
`${process.env.REACT_APP_BROKER_URL}/iceservers`
);
if (!response.ok) {
throw Error("Unable to fetch ICE servers");
}

View File

@@ -1,176 +0,0 @@
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import {
Box,
Flex,
Text,
Message,
Button,
Input,
Label,
Radio,
} from "theme-ui";
import { useLocation } from "react-router-dom";
import Footer from "../components/Footer";
import ErrorBanner from "../components/banner/ErrorBanner";
import LoadingOverlay from "../components/LoadingOverlay";
import { Stripe } from "@stripe/stripe-js";
type Price = { price?: string; name: string; value: number };
const prices: Price[] = [
{ price: "$5.00", name: "Small", value: 5 },
{ price: "$15.00", name: "Medium", value: 15 },
{ price: "$30.00", name: "Large", value: 30 },
];
function Donate() {
const location = useLocation();
const query = new URLSearchParams(location.search);
const hasDonated = query.has("success");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [stripe, setStripe] = useState<Stripe>();
useEffect(() => {
import("@stripe/stripe-js").then(({ loadStripe }) => {
loadStripe(process.env.REACT_APP_STRIPE_API_KEY as string)
.then((stripe) => {
if (stripe) {
setStripe(stripe);
setLoading(false);
}
})
.catch((error) => {
// TODO: check setError -> cannot work with value as a string
setError(error.message);
setLoading(false);
});
});
}, []);
async function handleSubmit(event: FormEvent<HTMLDivElement>) {
event.preventDefault();
if (loading) {
return;
}
const response = await fetch(
process.env.REACT_APP_STRIPE_URL + "/create-checkout-session",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ currency: "usd", amount: value * 100 }),
}
);
const session = await response.json();
const result = await stripe?.redirectToCheckout({ sessionId: session.id });
if (result?.error) {
const stripeError = new Error(result.error.message);
setError(stripeError);
}
}
const [selectedPrice, setSelectedPrice] = useState("Medium");
const [value, setValue] = useState(15);
function handlePriceChange(price: Price) {
setValue(price.value);
setSelectedPrice(price.name);
}
return (
<Flex
sx={{
flexDirection: "column",
justifyContent: "space-between",
minHeight: "100%",
alignItems: "center",
}}
>
<Flex
sx={{
flexDirection: "column",
maxWidth: "350px",
width: "100%",
flexGrow: 1,
}}
m={4}
as="form"
onSubmit={handleSubmit}
>
<Text my={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Donate
</Text>
{hasDonated ? (
<Message my={2}>Thanks for donating!</Message>
) : (
<Text variant="body2" as="p">
In order to keep Owlbear Rodeo running any donation is greatly
appreciated.
</Text>
)}
<Text
my={4}
variant="heading"
as="h1"
sx={{ fontSize: 5, alignSelf: "center" }}
aria-hidden="true"
>
()*:
</Text>
<Text as="p" mb={2} variant="caption">
One time donation (USD)
</Text>
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
{prices.map((price) => (
<Label mx={1} key={price.name} sx={{ width: "initial" }}>
<Radio
name="donation"
checked={selectedPrice === price.name}
onChange={() => handlePriceChange(price)}
/>
{price.price}
</Label>
))}
<Label mx={1} sx={{ width: "initial" }}>
<Radio
name="donation"
checked={selectedPrice === "Custom"}
onChange={() => handlePriceChange({ value, name: "Custom" })}
/>
Custom
</Label>
</Box>
{selectedPrice === "Custom" && (
<Box>
<Label htmlFor="donation">Amount ($)</Label>
<Input
type="number"
name="donation"
min={1}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setValue(parseInt(e.target.value))
}
/>
</Box>
)}
<Button my={3} disabled={loading || !value}>
Go to Payment
</Button>
</Flex>
<Footer />
{loading && <LoadingOverlay />}
<ErrorBanner error={error} onRequestClose={() => setError(undefined)} />
</Flex>
);
}
export default Donate;

View File

@@ -71,12 +71,12 @@ function Home() {
Join Game
</Button>
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
Beta v{process.env.REACT_APP_VERSION}
Legacy v{process.env.REACT_APP_VERSION}
</Text>
<Button
as="a"
// @ts-ignore
href="https://owlbear.app/"
href="https://owlbear.rodeo/"
mt={4}
mx={2}
mb={2}
@@ -86,7 +86,7 @@ function Home() {
justifyContent: "center",
}}
>
Owlbear Rodeo 2.0 Beta
Owlbear Rodeo 2.0
</Button>
<Flex mb={4} mt={0} sx={{ justifyContent: "center" }}>
<Link href="https://www.reddit.com/r/OwlbearRodeo/">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -2462,11 +2462,6 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
"@stripe/stripe-js@^1.44.1":
version "1.44.1"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.44.1.tgz#376fdbed2b394c84deaa2041b8029b97e7eab3a7"
integrity sha512-DKj3U6tS+sCNsSXsoZbOl5gDrAVD3cAZ9QCiVSykLC3iJo085kkmw/3BAACRH54Bq2bN34yySuH6G1SLh2xHXA==
"@styled-system/background@^5.1.2":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@styled-system/background/-/background-5.1.2.tgz#75c63d06b497ab372b70186c0bf608d62847a2ba"