2 Commits

Author SHA1 Message Date
nthouliss
560750b15d Remove owlbear rodeo 1.0 2023-07-19 10:08:55 +10:00
nthouliss
af6e4908f4 Add migration modal 2023-07-14 12:58:10 +10:00
92 changed files with 314 additions and 3990 deletions

View File

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

6
.env
View File

@@ -1,3 +1,9 @@
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,2 +1,10 @@
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

View File

@@ -1,13 +0,0 @@
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."

View File

@@ -1,27 +0,0 @@
# 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,126 +1,7 @@
# Owlbear Rodeo Legacy
# Owlbear Rodeo
![Demo Image](/demo.jpg)
Tooling:
- [Peer JS](https://peerjs.com/docs.html)
- [Theme UI](https://theme-ui.com/)
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)

View File

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

View File

@@ -1,44 +0,0 @@
{
"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"]
}
}
}
}

View File

@@ -1,24 +0,0 @@
# 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
View File

@@ -1,117 +0,0 @@
# 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/**

View File

@@ -1,31 +0,0 @@
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"]

View File

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

View File

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

View File

@@ -1,39 +0,0 @@
{
"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

@@ -1,56 +0,0 @@
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

@@ -1,30 +0,0 @@
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

@@ -1,37 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,87 +0,0 @@
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

@@ -1,260 +0,0 @@
/* 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

@@ -1,67 +0,0 @@
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

@@ -1,21 +0,0 @@
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

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

View File

@@ -1,22 +0,0 @@
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>[];
};

View File

@@ -1,79 +0,0 @@
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

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,13 +0,0 @@
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

@@ -1,70 +0,0 @@
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>;

View File

@@ -1,18 +0,0 @@
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>;

View File

@@ -1,39 +0,0 @@
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

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

View File

@@ -1,38 +0,0 @@
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

@@ -1,15 +0,0 @@
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;
};

View File

@@ -1,18 +0,0 @@
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

@@ -1,21 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
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

@@ -1,37 +0,0 @@
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

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

View File

@@ -1,19 +0,0 @@
{
"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/**/*"]
}

File diff suppressed because it is too large Load Diff

BIN
demo.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -1,22 +0,0 @@
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,6 +10,7 @@
"@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,7 +5,10 @@
<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 but vintage" />
<meta
name="description"
content="Tabletop map sharing ʕノ•ᴥ•ʔノ┬─┬ヽʕ•ᴥ•ヽʔ"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
@@ -21,9 +24,12 @@
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 Legacy Edition</title>
<title>Owlbear Rodeo</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>

BIN
public/nestling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,54 +0,0 @@
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,59 +1,12 @@
import { ThemeProvider } from "theme-ui";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import theme from "./theme";
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";
import MigrationModal from "./modals/MigrationModal";
function App() {
return (
<ThemeProvider theme={theme}>
<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>
<MigrationModal />
</ThemeProvider>
);
}

View File

@@ -0,0 +1,40 @@
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,3 +5,15 @@
### 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

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

176
src/routes/Donate.tsx Normal file
View File

@@ -0,0 +1,176 @@
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" }}>
Legacy v{process.env.REACT_APP_VERSION}
Beta v{process.env.REACT_APP_VERSION}
</Text>
<Button
as="a"
// @ts-ignore
href="https://owlbear.rodeo/"
href="https://owlbear.app/"
mt={4}
mx={2}
mb={2}
@@ -86,7 +86,7 @@ function Home() {
justifyContent: "center",
}}
>
Owlbear Rodeo 2.0
Owlbear Rodeo 2.0 Beta
</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: 22 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -2462,6 +2462,11 @@
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"