Merge pull request #65 from owlbear-rodeo/feature/open-source

Feature/open source
This commit is contained in:
Mitchell McCaffrey 2023-06-16 12:41:37 +10:00 committed by GitHub
commit 11f96aee1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 3926 additions and 270 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

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"]

123
README.md
View File

@ -1,7 +1,122 @@
# 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 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 we're spending more time working on Owlbear Rodeo then 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 had 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 by 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 a IndexedDB database. I think this is a cool idea but browsers hate it when you do this. There are a 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 user 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 costs 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
### 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
```
### Production
Owlbear Rodeo is designed to be hosted in a cloud environment.
To make this easier we have provided a blueprint that will allow you to host it on [Render](https://render.com/).
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)
## 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](https://choosealicense.com/no-permission/) for commercial use.
This includes using the project as is or modifying/using the code here in any way for commercial purposes.
If you're interested in using Owlbear Rodeo in a commercial purpose 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/index.js",
"start": "node --es-module-specifier-resolution=node ./build/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>

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: "feature/open-source"
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: "feature/open-source" # optional (defaults to master)
rootDir: ./backend
dockerfilePath: ./Dockerfile
numInstances: 1 # optional (defaults to 1)
healthCheckPath: /health
envVars:
- key: ORIGIN_WHITELIST
fromService:
type: web
name: "Owlbear Rodeo Legacy"
envVarKey: RENDER_EXTERNAL_HOSTNAME
- key: PORT
value: 9000

View File

@ -16,7 +16,6 @@ import { DatabaseProvider } from "./contexts/DatabaseContext";
import { UserIdProvider } from "./contexts/UserIdContext";
import { ToastProvider } from "./components/Toast";
import { MigrationNotification } from "./MigrationNotification";
function App() {
return (
@ -25,14 +24,12 @@ function App() {
<AuthProvider>
<KeyboardProvider>
<ToastProvider>
<MigrationNotification />
<Router>
<Switch>
{/* Legacy support camel case routes */}
<Route path={["/howTo", "/how-to"]}>
<Route path="/how-to">
<HowTo />
</Route>
<Route path={["/releaseNotes", "/release-notes"]}>
<Route path="/release-notes">
<ReleaseNotes />
</Route>
<Route path="/about">

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

@ -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"