Move the webapp into its own folder

This commit is contained in:
iamBadgers
2025-06-02 17:56:58 -07:00
parent cf3abe92f5
commit f6fcf82eae
51 changed files with 2 additions and 2 deletions

33
app/backend/.eslintrc.js Normal file
View File

@@ -0,0 +1,33 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js,cjs}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

6766
app/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
app/backend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"dev": "npx ts-node-dev src/app.ts",
"build": "webpack && cp package.json ./dist/package.json"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/express": "^4.17.21",
"@types/sequelize": "^4.28.20",
"@types/sqlite3": "^3.1.11",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"crossenv": "^0.0.2-security",
"eslint": "^8.57.0",
"node-polyfill-webpack-plugin": "^4.0.0",
"prettier": "^3.2.5",
"ts-loader": "^9.5.1",
"typescript": "^5.4.5",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@types/body-parser": "^1.19.5",
"asty-astq": "^1.14.0",
"body-parser": "^1.20.2",
"express": "^4.18.3",
"memcached": "^2.2.2",
"net": "^1.0.2",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"tokenizr": "^1.7.0",
"webpack-node-externals": "^3.0.0"
}
}

40
app/backend/src/app.ts Normal file
View File

@@ -0,0 +1,40 @@
import express from 'express'
import { json } from 'body-parser'
import { Sequelize, Op, QueryTypes } from 'sequelize'
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser } from './tokenizer'
import { addGameApis } from './gameservice'
import { addCharacterApis } from './characterservice'
import { addRushStatsApis } from './rushstatsservice'
import { addDmApis } from './dmservice'
import { Memcache } from './memcache'
var Memcached = require('memcached')
const app = express()
const jsonParser = json()
const port = process.argv[process.argv.indexOf('-p')]
? process.argv[process.argv.indexOf('-p') + 1]
: 3001
const memcache_addr = process.argv[process.argv.indexOf('-m')]
? process.argv[process.argv.indexOf('-m') + 1]
: 'localhost:11211'
// const memcache = new memcached('localhost:11211', {})
const memcachep = new Memcache(memcache_addr)
addGameApis(app, jsonParser, memcachep)
addCharacterApis(app, jsonParser, memcachep)
addRushStatsApis(app, jsonParser, memcachep)
addDmApis(app, jsonParser, memcachep)
app.use(express.static(__dirname + '/frontend'))
app.use('/', (req, res) => {
res.sendFile(__dirname + '/frontend/index.html')
})
app.listen(port, async () => {
await database.authenticate()
console.log(`Using memcache at ${memcache_addr}`)
console.log(`Express is listening at http://localhost:${port}`)
return
})

View File

@@ -0,0 +1,97 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addCharacterApis(app, jsonParser, memcace) {
app.get('/api/character/:characterId', async (req, res) => {
try {
const character = await Character.findOne({
attributes: ['id', 'characterName', 'playerName', 'role', 'creationDate', 'status'],
where: { id: req.params['characterId'] }
})
if (character) {
res.send(character)
} else {
res.status(404).send('Cannot find character.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const characterData = await Character.findAll({
offset: page * count,
limit: count,
order: orderBy,
where: filter
})
const totalCount = await Character.count({
where: filter
})
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({ characterData, pageCount, totalCount })
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
app.post('/api/character/:characterId/apps', async (req, res) => {
try {
const apps = await App.findAll({ where: { characterId: req.params['characterId'] } })
if (apps) {
res.send(apps)
} else {
res.status(400).send('Could not find apps.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character/:characterId/picks', async (req, res) => {
try {
const picks = await Pick.findAll({ where: { characterId: req.params['characterId'] } })
if (picks) {
res.send(picks)
} else {
res.status(400).send('Could not find picks.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character/:characterId/gameHistory', async (req, res) => {
try {
const gameHistory = await Character.findOne({
include: [
{ model: Game, as: 'characterPickedForGame' },
{ model: Game, as: 'characterAppliedForGame' }
],
where: { id: req.params['characterId'] }
})
if (gameHistory) {
res.send(gameHistory)
} else {
res.status(404).send('Could nor find character.')
}
} catch (e) {
res.status(500).send(e)
}
})
}

102
app/backend/src/db.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Sequelize, Model, DataTypes } from 'sequelize'
export type RoleName =
| 'Fixer'
| 'Tech'
| 'Medtech'
| 'Media'
| 'Netrunner'
| 'Solo'
| 'Nomad'
| 'Exec'
| 'Lawman'
| 'Rocker'
export const roleNames: RoleName[] = [
'Fixer',
'Tech',
'Medtech',
'Media',
'Netrunner',
'Solo',
'Nomad',
'Exec',
'Lawman',
'Rocker'
]
const databasePath = './testdb.db'
export const database = new Sequelize({
dialect: 'sqlite',
storage: databasePath
})
export const Character = database.define(
'Characters',
{
id: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT },
playerName: { type: DataTypes.TEXT },
role: { type: DataTypes.TEXT },
creationDate: { type: DataTypes.INTEGER },
status: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
export const Game = database.define(
'Games',
{
id: { type: DataTypes.INTEGER, primaryKey: true },
title: { type: DataTypes.TEXT },
status: { type: DataTypes.TEXT },
fix: { type: DataTypes.BOOLEAN },
postdate: { type: DataTypes.INTEGER },
gamemaster: { type: DataTypes.TEXT },
payoutEB: { type: DataTypes.INTEGER },
payoutIP: { type: DataTypes.INTEGER },
payoutLoot: { type: DataTypes.INTEGER }
},
{ timestamps: false }
)
export const Pick = database.define(
'Picks',
{
gameId: { type: DataTypes.INTEGER, primaryKey: true },
gameTitle: { type: DataTypes.TEXT },
characterId: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
export const App = database.define(
'Apps',
{
gameId: { type: DataTypes.INTEGER, primaryKey: true },
gameTitle: { type: DataTypes.TEXT },
characterId: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
// Bind characters to applications and picks
Character.hasMany(App, { foreignKey: 'characterId', as: 'appliedCharacter' })
App.belongsTo(Character, { foreignKey: 'characterId', as: 'appliedCharacter' })
Character.hasMany(Pick, { foreignKey: 'characterId', as: 'pickedCharacter' })
Pick.belongsTo(Character, { foreignKey: 'characterId', as: 'pickedCharacter' })
// Bind games to applications and picks
Game.hasMany(App, { foreignKey: 'gameId', as: 'gameApplications' })
App.belongsTo(Game, { foreignKey: 'gameId', as: 'gameApplications' })
Game.hasMany(Pick, { foreignKey: 'gameId', as: 'gamePicks' })
Pick.belongsTo(Game, { foreignKey: 'gameId', as: 'gamePicks' })
// Bind picked characters to games.
Game.belongsToMany(Character, { through: 'Apps', as: 'characterAppliedForGame' })
Character.belongsToMany(Game, { through: 'Apps', as: 'characterAppliedForGame' })
Game.belongsToMany(Character, { through: 'Picks', as: 'characterPickedForGame' })
Character.belongsToMany(Game, { through: 'Picks', as: 'characterPickedForGame' })

View File

@@ -0,0 +1,58 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
import { fn, col } from 'sequelize'
export function addDmApis(app, jsonParser, memcache) {
app.get('/api/dm/:dmName', async (req, res) => {
const dmName = req.params.dmName
let games = await Game.findAll({
where: { gamemaster: dmName }
})
res.send(games)
})
app.post('/api/dm', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page || 0
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const dms = await Game.findAll({
offset: page * count,
limit: count,
attributes: ['gamemaster', [fn('count', col('id')), 'gamecount']],
group: ['gamemaster'],
order: orderBy,
where: filter
})
const totalCount = (
await Game.count({
group: ['gamemaster'],
where: filter
})
).length
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({
dms,
pageCount,
totalCount
})
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
}

View File

@@ -0,0 +1,82 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addGameApis(app, jsonParser, memcache) {
app.get('/api/game/:gameId', async (req, res) => {
try {
const game = await Game.findOne({ where: { id: req.params['gameId'] } })
if (!game) {
res.status(404).send('Game Not Found')
} else {
res.send(game)
}
} catch (e) {
console.log(e)
res.status(500).send(e)
}
})
app.post('/api/game', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page || 0
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const gameData = await Game.findAll({
offset: page * count,
limit: count,
order: orderBy,
where: filter
})
const totalCount = await Game.count({
where: filter
})
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({ gameData, pageCount, totalCount })
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
app.post('/api/game/:gameId/apps', async (req, res) => {
try {
const apps = await App.findAll({
include: { model: Character, as: 'appliedCharacter' },
where: { gameId: req.params['gameId'] }
})
if (!apps) {
res.status(404).send('Apps not found.')
} else {
res.send(apps)
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/game/:gameId/picks', async (req, res) => {
try {
const picks = await Pick.findAll({
include: { model: Character, as: 'pickedCharacter' },
where: { gameId: req.params['gameId'] }
})
if (!picks) {
res.status(404).send('Picks not found')
} else {
res.send(picks)
}
} catch (e) {
res.status(500).send(e)
}
})
}

View File

@@ -0,0 +1,29 @@
import { promisify } from 'util'
var Memcached = require('memcached')
export class Memcache {
readonly memcached
readonly getCallback
readonly setCallback
readonly replaceCallback
constructor(url: string) {
this.memcached = new Memcached(url, {
timeout: 30
})
this.getCallback = promisify(this.memcached.get).bind(this.memcached)
this.setCallback = promisify(this.memcached.set).bind(this.memcached)
this.replaceCallback = promisify(this.memcached.replace).bind(this.memcached)
}
async get(key: string) {
return this.getCallback(key)
}
async set(key: string, value) {
return this.setCallback(key, value, 200)
}
}

View File

@@ -0,0 +1,134 @@
import { database, Character, Game, Pick, App, roleNames } from './db'
import { Op, QueryTypes } from 'sequelize'
const serverGameStatsQuery =
'SELECT ' +
'COUNT(*) as TotalGames, ' +
'SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as Complete, ' +
'SUM(CASE WHEN status = "Postponed" THEN 1 ELSE 0 END) as Postponed, ' +
'SUM(CASE WHEN status = "Pending" THEN 1 ELSE 0 END) as Pending, ' +
'SUM(CASE WHEN event = "TRUE" THEN 1 ELSE 0 END) as Events, ' +
'SUM(CASE WHEN fix = "TRUE" THEN 1 ELSE 0 END) as Fixes, ' +
'SUM(payoutEB) as TotalEB, ' +
'SUM(payoutIP) as TotalIP, ' +
'SUM(payoutEB) / SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as AverageEB, ' +
'SUM(payoutIP) / SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as AverageIP ' +
'FROM Games WHERE postdate BETWEEN :startSeconds AND :endSeconds'
const startingYear = 2023
function getMonthNumber(monthId: number): number {
return monthId % 12
}
function getYearNumber(monthId: number): number {
return Math.floor(monthId / 12 + startingYear)
}
function monthIdToStartSeconds(monthId: number): number {
if (monthId != -1) {
const yearNumber = getYearNumber(monthId)
const monthNumber = getMonthNumber(monthId)
return new Date(yearNumber, monthNumber, 1).getTime() / 1000
}
return 0
}
function monthIdToEndSeconds(monthId: number): number {
if (monthId != -1) {
const yearNumber = getYearNumber(monthId)
const monthNumber = getMonthNumber(monthId)
return new Date(yearNumber, monthNumber + 1, -1).getTime() / 1000
}
return Number.MAX_SAFE_INTEGER
}
export function addRushStatsApis(app, jsonParser, memcache) {
app.post('/api/serverstats/gamestats', jsonParser, async (req, res) => {
const monthId = req.body.monthId
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('gamestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'gamestats' + req.body.monthId)
res.send(cachedResponse)
return
}
console.log('cache miss')
const serverGameStats = await database.query(serverGameStatsQuery, {
type: QueryTypes.SELECT,
replacements: {
startSeconds,
endSeconds
}
})
await memcache.set('gamestats' + req.body.monthId, serverGameStats[0])
res.send(serverGameStats[0])
})
app.post('/api/serverstats/rolestats', jsonParser, async (req, res) => {
const monthId = req.body.monthId
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('rolestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'rolestats' + req.body.monthId)
res.send(cachedResponse)
return
}
const games = await Game.findAll({
include: [
{ model: Character, as: 'characterPickedForGame' },
{ model: Character, as: 'characterAppliedForGame' }
],
where: { postdate: { [Op.between]: [startSeconds, endSeconds] } }
})
// count active roles
let activeCharacters = new Map()
let pickedCharacterCount = new Map()
let appedCharacterCount = new Map()
games.forEach((game, gameNum) => {
const picks = game.dataValues.characterPickedForGame
const appls = game.dataValues.characterAppliedForGame
picks.forEach((character, characterNum) => {
const role = character.dataValues.role
// Count role application
pickedCharacterCount.set(role, (pickedCharacterCount.get(role) || 0) + 1)
})
appls.forEach((character, characterNum) => {
const role = character.dataValues.role
const charId = character.dataValues.id
// Add apllied characters to active list
if (!activeCharacters.has(role)) {
activeCharacters.set(role, new Set())
}
activeCharacters.get(role).add(charId)
// Count role application
appedCharacterCount.set(role, (appedCharacterCount.get(role) || 0) + 1)
})
})
const result = {}
roleNames.forEach((roleName) => {
result[roleName] = {
apps: appedCharacterCount.get(roleName) || 0,
picks: pickedCharacterCount.get(roleName) || 0,
active: activeCharacters.has(roleName) ? activeCharacters.get(roleName).size : 0
}
})
await memcache.set('rolestats' + req.body.monthId, result)
res.send(result)
})
}

View File

@@ -0,0 +1,177 @@
import Tokenizr from 'tokenizr'
import { Op } from 'sequelize'
export { ParsingError } from 'tokenizr'
export class OrderByParser {
lexer = new Tokenizr()
constructor() {
this.lexer.rule(/,/, (ctx, m) => {
ctx.accept('spacer')
})
this.lexer.rule(/ASC|DESC/, (ctx, m) => {
ctx.accept('direction', m[0])
})
this.lexer.rule(/[a-zA-Z]+/, (ctx, m) => {
ctx.accept('column', m[0])
})
this.lexer.rule(/\s/, (ctx, m) => {
ctx.ignore()
})
}
parse(orderBy: string) {
const output = []
let holding = []
this.lexer
.input(orderBy)
.tokens()
.forEach((token) => {
switch (token.type) {
case 'spacer':
output.push(holding)
holding = []
break
case 'column':
case 'direction':
holding.push(token.value)
break
}
})
if (holding) {
output.push(holding)
}
return output
}
}
const openGroupRegex = /\(/
const closeGroupRegex = /\)/
const conjunctinoRegex = /AND|OR/
const equalityRegex = /([a-zA-Z]+)\s?(=|!=|<|>|<=|>=|:)\s?([\d]+)/
const stringEqualityRegex = /([a-zA-Z]+)\s?(=|!=|<|>|<=|>=|:)\s?\"([a-zA-Z'"\d\s]*)\"/
const spacerRegex = /\s/
const opperatorMap = {
'=': Op.eq,
'!=': Op.ne,
'<=': Op.lte,
'>=': Op.gte,
'>': Op.gt,
'<': Op.lt,
':': Op.like,
AND: Op.and,
OR: Op.or
}
export class FilterParser {
lexer: Tokenizr
constructor() {
this.lexer = new Tokenizr()
this.lexer.rule(openGroupRegex, (ctx, m) => {
ctx.accept('opengroup')
})
this.lexer.rule(closeGroupRegex, (ctx, m) => {
ctx.accept('closegroup')
})
this.lexer.rule(conjunctinoRegex, (ctx, m) => {
ctx.accept('conjunction', m[0])
})
this.lexer.rule(stringEqualityRegex, (ctx, m) => {
ctx.accept('column', m[1])
ctx.accept('opperator', m[2])
ctx.accept('value', m[3])
})
this.lexer.rule(equalityRegex, (ctx, m) => {
ctx.accept('column', m[1])
ctx.accept('opperator', m[2])
ctx.accept('value', m[3])
})
this.lexer.rule(spacerRegex, (ctx, m) => {
ctx.ignore()
})
}
parse(filter: string) {
this.lexer.input(filter)
let block = this.parseBlock('AND')
this.lexer.consume('EOF')
this.lexer.reset()
return block
}
private parseBlock(conjunciton: string) {
let items = []
let activeCon = conjunciton
for (;;) {
let nextItem = this.lexer.alternatives(
() => this.parseEquality(),
() => this.parseGroup(),
() => {
let conToken = this.lexer.consume('conjunction')
if (items.length === 1) {
activeCon = conToken.value
}
if (conToken.value === activeCon) {
return this.parseEquality()
} else {
return this.parseBlock(conToken.value)
}
},
() => this.parseEmpty()
)
if (nextItem === undefined) {
break
}
items.push(nextItem)
}
return { [opperatorMap[activeCon]]: items }
}
private parseEquality() {
let columnToken = this.lexer.consume('column')
let opperatorToken = this.lexer.consume('opperator')
let valueToken = this.lexer.consume('value')
if (opperatorToken.value === ':') {
return {
[columnToken.value]: {
[opperatorMap[opperatorToken.value]]: `%${valueToken.value.toString()}%`
}
}
} else {
return {
[columnToken.value]: { [opperatorMap[opperatorToken.value]]: valueToken.value.toString() }
}
}
}
private parseGroup() {
this.lexer.consume('opengroup')
let block = this.parseBlock('AND')
this.lexer.consume('closegroup')
return block
}
private parseEmpty() {
return undefined
}
}

11
app/backend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist"
},
"lib": ["es2015"]
}

View File

@@ -0,0 +1,30 @@
const path = require('path');
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin")
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'production',
entry: './src/app.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
}, node: {
__dirname: false
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'app.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [
new NodePolyfillPlugin(),
],
externals: [nodeExternals()],
};