switch to typescript
This commit is contained in:
parent
031ca31c7e
commit
23559b1a3b
63 changed files with 7943 additions and 1397 deletions
12
src/server/Dirs.ts
Normal file
12
src/server/Dirs.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const BASE_DIR = `${__dirname}/../..`
|
||||
|
||||
export const DATA_DIR = `${BASE_DIR}/data`
|
||||
export const UPLOAD_DIR = `${BASE_DIR}/data/uploads`
|
||||
export const UPLOAD_URL = `/uploads`
|
||||
export const PUBLIC_DIR = `${BASE_DIR}/build/public/`
|
||||
70
src/server/Game.ts
Normal file
70
src/server/Game.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import GameCommon from './../common/GameCommon'
|
||||
import Util from './../common/Util'
|
||||
import { Rng } from '../common/Rng'
|
||||
import GameLog from './GameLog'
|
||||
import { createPuzzle } from './Puzzle'
|
||||
import Protocol from '../common/Protocol'
|
||||
import GameStorage from './GameStorage'
|
||||
|
||||
async function createGameObject(gameId: string, targetTiles: number, image: { file: string, url: string }, ts: number, scoreMode: number) {
|
||||
const seed = Util.hash(gameId + ' ' + ts)
|
||||
const rng = new Rng(seed)
|
||||
return {
|
||||
id: gameId,
|
||||
rng: { type: 'Rng', obj: rng },
|
||||
puzzle: await createPuzzle(rng, targetTiles, image, ts),
|
||||
players: [],
|
||||
evtInfos: {},
|
||||
scoreMode,
|
||||
}
|
||||
}
|
||||
|
||||
async function createGame(gameId: string, targetTiles: number, image: { file: string, url: string }, ts: number, scoreMode: number) {
|
||||
const gameObject = await createGameObject(gameId, targetTiles, image, ts, scoreMode)
|
||||
|
||||
GameLog.create(gameId)
|
||||
GameLog.log(gameId, Protocol.LOG_HEADER, 1, targetTiles, image, ts, scoreMode)
|
||||
|
||||
GameCommon.setGame(gameObject.id, gameObject)
|
||||
GameStorage.setDirty(gameId)
|
||||
}
|
||||
|
||||
function addPlayer(gameId: string, playerId: string, ts: number) {
|
||||
const idx = GameCommon.getPlayerIndexById(gameId, playerId)
|
||||
const diff = ts - GameCommon.getStartTs(gameId)
|
||||
if (idx === -1) {
|
||||
GameLog.log(gameId, Protocol.LOG_ADD_PLAYER, playerId, diff)
|
||||
} else {
|
||||
GameLog.log(gameId, Protocol.LOG_UPDATE_PLAYER, idx, diff)
|
||||
}
|
||||
|
||||
GameCommon.addPlayer(gameId, playerId, ts)
|
||||
GameStorage.setDirty(gameId)
|
||||
}
|
||||
|
||||
function handleInput(gameId: string, playerId: string, input: any, ts: number) {
|
||||
const idx = GameCommon.getPlayerIndexById(gameId, playerId)
|
||||
const diff = ts - GameCommon.getStartTs(gameId)
|
||||
GameLog.log(gameId, Protocol.LOG_HANDLE_INPUT, idx, input, diff)
|
||||
|
||||
const ret = GameCommon.handleInput(gameId, playerId, input, ts)
|
||||
GameStorage.setDirty(gameId)
|
||||
return ret
|
||||
}
|
||||
|
||||
export default {
|
||||
createGameObject,
|
||||
createGame,
|
||||
addPlayer,
|
||||
handleInput,
|
||||
getAllGames: GameCommon.getAllGames,
|
||||
getActivePlayers: GameCommon.getActivePlayers,
|
||||
getFinishedTileCount: GameCommon.getFinishedTileCount,
|
||||
getImageUrl: GameCommon.getImageUrl,
|
||||
getTileCount: GameCommon.getTileCount,
|
||||
exists: GameCommon.exists,
|
||||
playerExists: GameCommon.playerExists,
|
||||
get: GameCommon.get,
|
||||
getStartTs: GameCommon.getStartTs,
|
||||
getFinishTs: GameCommon.getFinishTs,
|
||||
}
|
||||
51
src/server/GameLog.ts
Normal file
51
src/server/GameLog.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import fs from 'fs'
|
||||
import { logger } from '../common/Util.js'
|
||||
import { DATA_DIR } from '../server/Dirs.js'
|
||||
|
||||
const log = logger('GameLog.js')
|
||||
|
||||
const filename = (gameId: string) => `${DATA_DIR}/log_${gameId}.log`
|
||||
|
||||
const create = (gameId: string) => {
|
||||
const file = filename(gameId)
|
||||
if (!fs.existsSync(file)) {
|
||||
fs.appendFileSync(file, '')
|
||||
}
|
||||
}
|
||||
|
||||
const exists = (gameId: string) => {
|
||||
const file = filename(gameId)
|
||||
return fs.existsSync(file)
|
||||
}
|
||||
|
||||
const _log = (gameId: string, ...args: Array<any>) => {
|
||||
const file = filename(gameId)
|
||||
if (!fs.existsSync(file)) {
|
||||
return
|
||||
}
|
||||
const str = JSON.stringify(args)
|
||||
fs.appendFileSync(file, str + "\n")
|
||||
}
|
||||
|
||||
const get = (gameId: string) => {
|
||||
const file = filename(gameId)
|
||||
if (!fs.existsSync(file)) {
|
||||
return []
|
||||
}
|
||||
const lines = fs.readFileSync(file, 'utf-8').split("\n")
|
||||
return lines.filter((line: string) => !!line).map((line: string) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch (e) {
|
||||
log.log(line)
|
||||
log.log(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
exists,
|
||||
log: _log,
|
||||
get,
|
||||
}
|
||||
48
src/server/GameSockets.ts
Normal file
48
src/server/GameSockets.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { logger } from '../common/Util.js'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
const log = logger('GameSocket.js')
|
||||
|
||||
// Map<gameId, Socket[]>
|
||||
const SOCKETS = {} as Record<string, Array<WebSocket>>
|
||||
|
||||
function socketExists(gameId: string, socket: WebSocket) {
|
||||
if (!(gameId in SOCKETS)) {
|
||||
return false
|
||||
}
|
||||
return SOCKETS[gameId].includes(socket)
|
||||
}
|
||||
|
||||
function removeSocket(gameId: string, socket: WebSocket) {
|
||||
if (!(gameId in SOCKETS)) {
|
||||
return
|
||||
}
|
||||
SOCKETS[gameId] = SOCKETS[gameId].filter((s: WebSocket) => s !== socket)
|
||||
log.log('removed socket: ', gameId, socket.protocol)
|
||||
log.log('socket count: ', Object.keys(SOCKETS[gameId]).length)
|
||||
}
|
||||
|
||||
function addSocket(gameId: string, socket: WebSocket) {
|
||||
if (!(gameId in SOCKETS)) {
|
||||
SOCKETS[gameId] = []
|
||||
}
|
||||
if (!SOCKETS[gameId].includes(socket)) {
|
||||
SOCKETS[gameId].push(socket)
|
||||
log.log('added socket: ', gameId, socket.protocol)
|
||||
log.log('socket count: ', Object.keys(SOCKETS[gameId]).length)
|
||||
}
|
||||
}
|
||||
|
||||
function getSockets(gameId: string) {
|
||||
if (!(gameId in SOCKETS)) {
|
||||
return []
|
||||
}
|
||||
return SOCKETS[gameId]
|
||||
}
|
||||
|
||||
export default {
|
||||
addSocket,
|
||||
removeSocket,
|
||||
socketExists,
|
||||
getSockets,
|
||||
}
|
||||
93
src/server/GameStorage.ts
Normal file
93
src/server/GameStorage.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import fs from 'fs'
|
||||
import GameCommon from './../common/GameCommon'
|
||||
import Util, { logger } from './../common/Util'
|
||||
import { Rng } from '../common/Rng'
|
||||
import { DATA_DIR } from './Dirs'
|
||||
import Time from './../common/Time'
|
||||
|
||||
const log = logger('GameStorage.js')
|
||||
|
||||
const DIRTY_GAMES = {} as any
|
||||
function setDirty(gameId: string): void {
|
||||
DIRTY_GAMES[gameId] = true
|
||||
}
|
||||
function setClean(gameId: string): void {
|
||||
delete DIRTY_GAMES[gameId]
|
||||
}
|
||||
|
||||
function loadGames(): void {
|
||||
const files = fs.readdirSync(DATA_DIR)
|
||||
for (const f of files) {
|
||||
const m = f.match(/^([a-z0-9]+)\.json$/)
|
||||
if (!m) {
|
||||
continue
|
||||
}
|
||||
const gameId = m[1]
|
||||
loadGame(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
function loadGame(gameId: string): void {
|
||||
const file = `${DATA_DIR}/${gameId}.json`
|
||||
const contents = fs.readFileSync(file, 'utf-8')
|
||||
let game
|
||||
try {
|
||||
game = JSON.parse(contents)
|
||||
} catch {
|
||||
log.log(`[ERR] unable to load game from file ${file}`);
|
||||
}
|
||||
if (typeof game.puzzle.data.started === 'undefined') {
|
||||
game.puzzle.data.started = Math.round(fs.statSync(file).ctimeMs)
|
||||
}
|
||||
if (typeof game.puzzle.data.finished === 'undefined') {
|
||||
let unfinished = game.puzzle.tiles.map(Util.decodeTile).find((t: any) => t.owner !== -1)
|
||||
game.puzzle.data.finished = unfinished ? 0 : Time.timestamp()
|
||||
}
|
||||
if (!Array.isArray(game.players)) {
|
||||
game.players = Object.values(game.players)
|
||||
}
|
||||
const gameObject = {
|
||||
id: game.id,
|
||||
rng: {
|
||||
type: game.rng ? game.rng.type : '_fake_',
|
||||
obj: game.rng ? Rng.unserialize(game.rng.obj) : new Rng(0),
|
||||
},
|
||||
puzzle: game.puzzle,
|
||||
players: game.players,
|
||||
evtInfos: {},
|
||||
scoreMode: game.scoreMode || GameCommon.SCORE_MODE_FINAL,
|
||||
}
|
||||
GameCommon.setGame(gameObject.id, gameObject)
|
||||
}
|
||||
|
||||
function persistGames() {
|
||||
for (const gameId of Object.keys(DIRTY_GAMES)) {
|
||||
persistGame(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
function persistGame(gameId: string) {
|
||||
const game = GameCommon.get(gameId)
|
||||
if (game.id in DIRTY_GAMES) {
|
||||
setClean(game.id)
|
||||
}
|
||||
fs.writeFileSync(`${DATA_DIR}/${game.id}.json`, JSON.stringify({
|
||||
id: game.id,
|
||||
rng: {
|
||||
type: game.rng.type,
|
||||
obj: Rng.serialize(game.rng.obj),
|
||||
},
|
||||
puzzle: game.puzzle,
|
||||
players: game.players,
|
||||
scoreMode: game.scoreMode,
|
||||
}))
|
||||
log.info(`[INFO] persisted game ${game.id}`)
|
||||
}
|
||||
|
||||
export default {
|
||||
loadGames,
|
||||
loadGame,
|
||||
persistGames,
|
||||
persistGame,
|
||||
setDirty,
|
||||
}
|
||||
82
src/server/Images.ts
Normal file
82
src/server/Images.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import sizeOf from 'image-size'
|
||||
import fs from 'fs'
|
||||
import exif from 'exif'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import {UPLOAD_DIR, UPLOAD_URL} from './Dirs.js'
|
||||
|
||||
const resizeImage = async (filename: string) => {
|
||||
if (!filename.toLowerCase().match(/\.(jpe?g|webp|png)$/)) {
|
||||
return
|
||||
}
|
||||
|
||||
const imagePath = `${UPLOAD_DIR}/${filename}`
|
||||
const imageOutPath = `${UPLOAD_DIR}/r/${filename}`
|
||||
const orientation = await getExifOrientation(imagePath)
|
||||
|
||||
let sharpImg = sharp(imagePath, { failOnError: false })
|
||||
// when image is rotated to the left or right, switch width/height
|
||||
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
|
||||
if (orientation === 6) {
|
||||
sharpImg = sharpImg.rotate()
|
||||
} else if (orientation === 3) {
|
||||
sharpImg = sharpImg.rotate().rotate()
|
||||
} else if (orientation === 8) {
|
||||
sharpImg = sharpImg.rotate().rotate().rotate()
|
||||
}
|
||||
const sizes = [
|
||||
[150, 100],
|
||||
[375, 210],
|
||||
]
|
||||
for (let [w,h] of sizes) {
|
||||
console.log(w, h, imagePath)
|
||||
await sharpImg.resize(w, h, { fit: 'contain' }).toFile(`${imageOutPath}-${w}x${h}.webp`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getExifOrientation(imagePath: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
new exif.ExifImage({ image: imagePath }, function (error, exifData) {
|
||||
if (error) {
|
||||
resolve(0)
|
||||
} else {
|
||||
resolve(exifData.image.Orientation)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const allImages = () => {
|
||||
const images = fs.readdirSync(UPLOAD_DIR)
|
||||
.filter(f => f.toLowerCase().match(/\.(jpe?g|webp|png)$/))
|
||||
.map(f => ({
|
||||
filename: f,
|
||||
file: `${UPLOAD_DIR}/${f}`,
|
||||
url: `${UPLOAD_URL}/${encodeURIComponent(f)}`,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
return fs.statSync(b.file).mtime.getTime() -
|
||||
fs.statSync(a.file).mtime.getTime()
|
||||
})
|
||||
return images
|
||||
}
|
||||
|
||||
async function getDimensions(imagePath: string) {
|
||||
let dimensions = sizeOf(imagePath)
|
||||
const orientation = await getExifOrientation(imagePath)
|
||||
// when image is rotated to the left or right, switch width/height
|
||||
// https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
|
||||
if (orientation === 6 || orientation === 8) {
|
||||
return {
|
||||
width: dimensions.height,
|
||||
height: dimensions.width,
|
||||
}
|
||||
}
|
||||
return dimensions
|
||||
}
|
||||
|
||||
export default {
|
||||
allImages,
|
||||
resizeImage,
|
||||
getDimensions,
|
||||
}
|
||||
218
src/server/Puzzle.ts
Normal file
218
src/server/Puzzle.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import Util from '../common/Util'
|
||||
import { Rng } from '../common/Rng'
|
||||
import Images from './Images.js'
|
||||
|
||||
interface PuzzleInfo {
|
||||
width: number
|
||||
height: number
|
||||
tileSize: number
|
||||
tileMarginWidth: number
|
||||
tileDrawSize: number
|
||||
tiles: number
|
||||
tilesX: number
|
||||
tilesY: number
|
||||
}
|
||||
|
||||
// cut size of each puzzle tile in the
|
||||
// final resized version of the puzzle image
|
||||
const TILE_SIZE = 64
|
||||
|
||||
async function createPuzzle(
|
||||
rng: Rng,
|
||||
targetTiles: number,
|
||||
image: { file: string, url: string },
|
||||
ts: number
|
||||
) {
|
||||
const imagePath = image.file
|
||||
const imageUrl = image.url
|
||||
|
||||
// determine puzzle information from the image dimensions
|
||||
const dim = await Images.getDimensions(imagePath)
|
||||
if (!dim || !dim.width || !dim.height) {
|
||||
throw `[ 2021-05-16 invalid dimension for path ${imagePath} ]`
|
||||
}
|
||||
const info = determinePuzzleInfo(dim.width, dim.height, targetTiles)
|
||||
|
||||
let tiles = new Array(info.tiles)
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
tiles[i] = { idx: i }
|
||||
}
|
||||
const shapes = determinePuzzleTileShapes(rng, info)
|
||||
|
||||
let positions = new Array(info.tiles)
|
||||
for (let tile of tiles) {
|
||||
const coord = Util.coordByTileIdx(info, tile.idx)
|
||||
positions[tile.idx] ={
|
||||
// instead of info.tileSize, we use info.tileDrawSize
|
||||
// to spread the tiles a bit
|
||||
x: coord.x * info.tileSize * 1.5,
|
||||
y: coord.y * info.tileSize * 1.5,
|
||||
}
|
||||
}
|
||||
|
||||
const tableWidth = info.width * 3
|
||||
const tableHeight = info.height * 3
|
||||
|
||||
const off = info.tileSize * 1.5
|
||||
let last = {
|
||||
x: info.width - (1 * off),
|
||||
y: info.height - (2 * off),
|
||||
}
|
||||
let countX = Math.ceil(info.width / off) + 2
|
||||
let countY = Math.ceil(info.height / off) + 2
|
||||
|
||||
let diffX = off
|
||||
let diffY = 0
|
||||
let index = 0
|
||||
for (let pos of positions) {
|
||||
pos.x = last.x
|
||||
pos.y = last.y
|
||||
last.x+=diffX
|
||||
last.y+=diffY
|
||||
index++
|
||||
// did we move horizontally?
|
||||
if (diffX !== 0) {
|
||||
if (index === countX) {
|
||||
diffY = diffX
|
||||
countY++
|
||||
diffX = 0
|
||||
index = 0
|
||||
}
|
||||
} else {
|
||||
if (index === countY) {
|
||||
diffX = -diffY
|
||||
countX++
|
||||
diffY = 0
|
||||
index = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// then shuffle the positions
|
||||
positions = Util.shuffle(rng, positions)
|
||||
|
||||
tiles = tiles.map(tile => {
|
||||
return Util.encodeTile({
|
||||
idx: tile.idx, // index of tile in the array
|
||||
group: 0, // if grouped with other tiles
|
||||
z: 0, // z index of the tile
|
||||
|
||||
// who owns the tile
|
||||
// 0 = free for taking
|
||||
// -1 = finished
|
||||
// other values: id of player who has the tile
|
||||
owner: 0,
|
||||
|
||||
// physical current position of the tile (x/y in pixels)
|
||||
// this position is the initial position only and is the
|
||||
// value that changes when moving a tile
|
||||
pos: positions[tile.idx],
|
||||
})
|
||||
})
|
||||
|
||||
// Complete puzzle object
|
||||
return {
|
||||
// tiles array
|
||||
tiles,
|
||||
// game data for puzzle, data changes during the game
|
||||
data: {
|
||||
// TODO: maybe calculate this each time?
|
||||
maxZ: 0, // max z of all pieces
|
||||
maxGroup: 0, // max group of all pieces
|
||||
started: ts, // start timestamp
|
||||
finished: 0, // finish timestamp
|
||||
},
|
||||
// static puzzle information. stays same for complete duration of
|
||||
// the game
|
||||
info: {
|
||||
table: {
|
||||
width: tableWidth,
|
||||
height: tableHeight,
|
||||
},
|
||||
// information that was used to create the puzzle
|
||||
targetTiles: targetTiles,
|
||||
imageUrl,
|
||||
|
||||
width: info.width, // actual puzzle width (same as bitmap.width)
|
||||
height: info.height, // actual puzzle height (same as bitmap.height)
|
||||
tileSize: info.tileSize, // width/height of each tile (without tabs)
|
||||
tileDrawSize: info.tileDrawSize, // width/height of each tile (with tabs)
|
||||
tileMarginWidth: info.tileMarginWidth,
|
||||
// offset in x and y when drawing tiles, so that they appear to be at pos
|
||||
tileDrawOffset: (info.tileDrawSize - info.tileSize) / -2,
|
||||
// max distance between tile and destination that
|
||||
// makes the tile snap to destination
|
||||
snapDistance: info.tileSize / 2,
|
||||
tiles: info.tiles, // the final number of tiles in the puzzle
|
||||
tilesX: info.tilesX, // number of tiles each row
|
||||
tilesY: info.tilesY, // number of tiles each col
|
||||
// ( index => {x, y} )
|
||||
// this is not the physical coordinate, but
|
||||
// the tile_coordinate
|
||||
// this can be used to determine where the
|
||||
// final destination of a tile is
|
||||
shapes: shapes, // tile shapes
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function determinePuzzleTileShapes(
|
||||
rng: Rng,
|
||||
info: PuzzleInfo
|
||||
) {
|
||||
const tabs = [-1, 1]
|
||||
|
||||
const shapes = new Array(info.tiles)
|
||||
for (let i = 0; i < info.tiles; i++) {
|
||||
let coord = Util.coordByTileIdx(info, i)
|
||||
shapes[i] = {
|
||||
top: coord.y === 0 ? 0 : shapes[i - info.tilesX].bottom * -1,
|
||||
right: coord.x === info.tilesX - 1 ? 0 : Util.choice(rng, tabs),
|
||||
left: coord.x === 0 ? 0 : shapes[i - 1].right * -1,
|
||||
bottom: coord.y === info.tilesY - 1 ? 0 : Util.choice(rng, tabs),
|
||||
}
|
||||
}
|
||||
return shapes.map(Util.encodeShape)
|
||||
}
|
||||
|
||||
const determineTilesXY = (w: number, h: number, targetTiles: number) => {
|
||||
const w_ = w < h ? (w * h) : (w * w)
|
||||
const h_ = w < h ? (h * h) : (w * h)
|
||||
let size = 0
|
||||
let tiles = 0
|
||||
do {
|
||||
size++
|
||||
tiles = Math.floor(w_ / size) * Math.floor(h_ / size)
|
||||
} while (tiles >= targetTiles)
|
||||
size--
|
||||
return {
|
||||
tilesX: Math.round(w_ / size),
|
||||
tilesY: Math.round(h_ / size),
|
||||
}
|
||||
}
|
||||
|
||||
const determinePuzzleInfo = (w: number, h: number, targetTiles: number) => {
|
||||
const {tilesX, tilesY} = determineTilesXY(w, h, targetTiles)
|
||||
const tiles = tilesX * tilesY
|
||||
const tileSize = TILE_SIZE
|
||||
const width = tilesX * tileSize
|
||||
const height = tilesY * tileSize
|
||||
|
||||
const tileMarginWidth = tileSize * .5;
|
||||
const tileDrawSize = Math.round(tileSize + tileMarginWidth * 2)
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
tileSize,
|
||||
tileMarginWidth,
|
||||
tileDrawSize,
|
||||
tiles,
|
||||
tilesX,
|
||||
tilesY,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createPuzzle,
|
||||
}
|
||||
80
src/server/WebSocketServer.ts
Normal file
80
src/server/WebSocketServer.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import WebSocket from 'ws'
|
||||
import { logger } from '../common/Util.js'
|
||||
|
||||
const log = logger('WebSocketServer.js')
|
||||
|
||||
/*
|
||||
Example config
|
||||
|
||||
config = {
|
||||
hostname: 'localhost',
|
||||
port: 1338,
|
||||
connectstring: `ws://localhost:1338/ws`,
|
||||
}
|
||||
*/
|
||||
|
||||
class EvtBus {
|
||||
private _on: any
|
||||
constructor() {
|
||||
this._on = {} as any
|
||||
}
|
||||
|
||||
on(type: string, callback: Function) {
|
||||
this._on[type] = this._on[type] || []
|
||||
this._on[type].push(callback)
|
||||
}
|
||||
|
||||
dispatch(type: string, ...args: Array<any>) {
|
||||
(this._on[type] || []).forEach((cb: Function) => {
|
||||
cb(...args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class WebSocketServer {
|
||||
evt: EvtBus
|
||||
private _websocketserver: WebSocket.Server|null
|
||||
config: any
|
||||
|
||||
constructor(config: any) {
|
||||
this.config = config
|
||||
this._websocketserver = null
|
||||
|
||||
this.evt = new EvtBus()
|
||||
}
|
||||
|
||||
on(type: string, callback: Function) {
|
||||
this.evt.on(type, callback)
|
||||
}
|
||||
|
||||
listen() {
|
||||
this._websocketserver = new WebSocket.Server(this.config)
|
||||
this._websocketserver.on('connection', (socket: WebSocket, request: Request) => {
|
||||
const pathname = new URL(this.config.connectstring).pathname
|
||||
if (request.url.indexOf(pathname) !== 0) {
|
||||
log.log('bad request url: ', request.url)
|
||||
socket.close()
|
||||
return
|
||||
}
|
||||
socket.on('message', (data: any) => {
|
||||
log.log(`ws`, socket.protocol, data)
|
||||
this.evt.dispatch('message', {socket, data})
|
||||
})
|
||||
socket.on('close', () => {
|
||||
this.evt.dispatch('close', {socket})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this._websocketserver) {
|
||||
this._websocketserver.close()
|
||||
}
|
||||
}
|
||||
|
||||
notifyOne(data: any, socket: WebSocket) {
|
||||
socket.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
export default WebSocketServer
|
||||
282
src/server/main.ts
Normal file
282
src/server/main.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import WebSocketServer from './WebSocketServer'
|
||||
import WebSocket from 'ws'
|
||||
import express from 'express'
|
||||
import multer from 'multer'
|
||||
import Protocol from './../common/Protocol'
|
||||
import Util, { logger } from './../common/Util'
|
||||
import Game from './Game'
|
||||
import bodyParser from 'body-parser'
|
||||
import v8 from 'v8'
|
||||
import fs from 'fs'
|
||||
import GameLog from './GameLog'
|
||||
import GameSockets from './GameSockets'
|
||||
import Time from '../common/Time'
|
||||
import Images from './Images'
|
||||
import {
|
||||
UPLOAD_DIR,
|
||||
UPLOAD_URL,
|
||||
PUBLIC_DIR,
|
||||
} from './Dirs'
|
||||
import GameCommon from '../common/GameCommon'
|
||||
import GameStorage from './GameStorage'
|
||||
|
||||
let configFile = ''
|
||||
let last = ''
|
||||
for (const val of process.argv) {
|
||||
if (last === '-c') {
|
||||
configFile = val
|
||||
}
|
||||
last = val
|
||||
}
|
||||
|
||||
if (configFile === '') {
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const config = JSON.parse(String(fs.readFileSync(configFile)))
|
||||
|
||||
const log = logger('main.js')
|
||||
|
||||
const port = config.http.port
|
||||
const hostname = config.http.hostname
|
||||
const app = express()
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: UPLOAD_DIR,
|
||||
filename: function (req, file, cb) {
|
||||
cb(null , file.originalname);
|
||||
}
|
||||
})
|
||||
const upload = multer({storage}).single('file');
|
||||
|
||||
app.get('/api/conf', (req, res) => {
|
||||
res.send({
|
||||
WS_ADDRESS: config.ws.connectstring,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/newgame-data', (req, res) => {
|
||||
res.send({
|
||||
images: Images.allImages(),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/index-data', (req, res) => {
|
||||
const ts = Time.timestamp()
|
||||
const games = [
|
||||
...Game.getAllGames().map((game: any) => ({
|
||||
id: game.id,
|
||||
hasReplay: GameLog.exists(game.id),
|
||||
started: Game.getStartTs(game.id),
|
||||
finished: Game.getFinishTs(game.id),
|
||||
tilesFinished: Game.getFinishedTileCount(game.id),
|
||||
tilesTotal: Game.getTileCount(game.id),
|
||||
players: Game.getActivePlayers(game.id, ts).length,
|
||||
imageUrl: Game.getImageUrl(game.id),
|
||||
})),
|
||||
]
|
||||
|
||||
res.send({
|
||||
gamesRunning: games.filter(g => !g.finished),
|
||||
gamesFinished: games.filter(g => !!g.finished),
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/upload', (req, res) => {
|
||||
upload(req, res, async (err: any) => {
|
||||
if (err) {
|
||||
log.log(err)
|
||||
res.status(400).send("Something went wrong!");
|
||||
}
|
||||
|
||||
try {
|
||||
await Images.resizeImage(req.file.filename)
|
||||
} catch (err) {
|
||||
log.log(err)
|
||||
res.status(400).send("Something went wrong!");
|
||||
}
|
||||
|
||||
res.send({
|
||||
image: {
|
||||
file: `${UPLOAD_DIR}/${req.file.filename}`,
|
||||
url: `${UPLOAD_URL}/${req.file.filename}`,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/newgame', bodyParser.json(), async (req, res) => {
|
||||
log.log(req.body.tiles, req.body.image)
|
||||
const gameId = Util.uniqId()
|
||||
if (!Game.exists(gameId)) {
|
||||
const ts = Time.timestamp()
|
||||
await Game.createGame(
|
||||
gameId,
|
||||
req.body.tiles,
|
||||
req.body.image,
|
||||
ts,
|
||||
req.body.scoreMode
|
||||
)
|
||||
}
|
||||
res.send({ id: gameId })
|
||||
})
|
||||
|
||||
app.use('/uploads/', express.static(UPLOAD_DIR))
|
||||
app.use('/', express.static(PUBLIC_DIR))
|
||||
|
||||
const wss = new WebSocketServer(config.ws);
|
||||
|
||||
const notify = (data: any, sockets: Array<WebSocket>) => {
|
||||
// TODO: throttle?
|
||||
for (let socket of sockets) {
|
||||
wss.notifyOne(data, socket)
|
||||
}
|
||||
}
|
||||
|
||||
wss.on('close', async ({socket} : {socket: WebSocket}) => {
|
||||
try {
|
||||
const proto = socket.protocol.split('|')
|
||||
const clientId = proto[0]
|
||||
const gameId = proto[1]
|
||||
GameSockets.removeSocket(gameId, socket)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
wss.on('message', async ({socket, data} : { socket: WebSocket, data: any }) => {
|
||||
try {
|
||||
const proto = socket.protocol.split('|')
|
||||
const clientId = proto[0]
|
||||
const gameId = proto[1]
|
||||
const msg = JSON.parse(data)
|
||||
const msgType = msg[0]
|
||||
switch (msgType) {
|
||||
case Protocol.EV_CLIENT_INIT_REPLAY: {
|
||||
if (!GameLog.exists(gameId)) {
|
||||
throw `[gamelog ${gameId} does not exist... ]`
|
||||
}
|
||||
const log = GameLog.get(gameId)
|
||||
const game = await Game.createGameObject(
|
||||
gameId,
|
||||
log[0][2],
|
||||
log[0][3],
|
||||
log[0][4],
|
||||
log[0][5] || GameCommon.SCORE_MODE_FINAL
|
||||
)
|
||||
notify(
|
||||
[Protocol.EV_SERVER_INIT_REPLAY, Util.encodeGame(game), log],
|
||||
[socket]
|
||||
)
|
||||
} break
|
||||
|
||||
case Protocol.EV_CLIENT_INIT: {
|
||||
if (!Game.exists(gameId)) {
|
||||
throw `[game ${gameId} does not exist... ]`
|
||||
}
|
||||
const ts = Time.timestamp()
|
||||
Game.addPlayer(gameId, clientId, ts)
|
||||
GameSockets.addSocket(gameId, socket)
|
||||
const game = Game.get(gameId)
|
||||
notify(
|
||||
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
|
||||
[socket]
|
||||
)
|
||||
} break
|
||||
|
||||
case Protocol.EV_CLIENT_EVENT: {
|
||||
if (!Game.exists(gameId)) {
|
||||
throw `[game ${gameId} does not exist... ]`
|
||||
}
|
||||
const clientSeq = msg[1]
|
||||
const clientEvtData = msg[2]
|
||||
const ts = Time.timestamp()
|
||||
|
||||
let sendGame = false
|
||||
if (!Game.playerExists(gameId, clientId)) {
|
||||
Game.addPlayer(gameId, clientId, ts)
|
||||
sendGame = true
|
||||
}
|
||||
if (!GameSockets.socketExists(gameId, socket)) {
|
||||
GameSockets.addSocket(gameId, socket)
|
||||
sendGame = true
|
||||
}
|
||||
if (sendGame) {
|
||||
const game = Game.get(gameId)
|
||||
notify(
|
||||
[Protocol.EV_SERVER_INIT, Util.encodeGame(game)],
|
||||
[socket]
|
||||
)
|
||||
}
|
||||
|
||||
const changes = Game.handleInput(gameId, clientId, clientEvtData, ts)
|
||||
notify(
|
||||
[Protocol.EV_SERVER_EVENT, clientId, clientSeq, changes],
|
||||
GameSockets.getSockets(gameId)
|
||||
)
|
||||
} break
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
GameStorage.loadGames()
|
||||
const server = app.listen(
|
||||
port,
|
||||
hostname,
|
||||
() => log.log(`server running on http://${hostname}:${port}`)
|
||||
)
|
||||
wss.listen()
|
||||
|
||||
|
||||
const memoryUsageHuman = () => {
|
||||
const totalHeapSize = v8.getHeapStatistics().total_available_size
|
||||
let totalHeapSizeInGB = (totalHeapSize / 1024 / 1024 / 1024).toFixed(2)
|
||||
|
||||
log.log(`Total heap size (bytes) ${totalHeapSize}, (GB ~${totalHeapSizeInGB})`)
|
||||
const used = process.memoryUsage().heapUsed / 1024 / 1024
|
||||
log.log(`Mem: ${Math.round(used * 100) / 100}M`)
|
||||
}
|
||||
|
||||
memoryUsageHuman()
|
||||
|
||||
// persist games in fixed interval
|
||||
const persistInterval = setInterval(() => {
|
||||
log.log('Persisting games...')
|
||||
GameStorage.persistGames()
|
||||
|
||||
memoryUsageHuman()
|
||||
}, config.persistence.interval)
|
||||
|
||||
const gracefulShutdown = (signal: any) => {
|
||||
log.log(`${signal} received...`)
|
||||
|
||||
log.log('clearing persist interval...')
|
||||
clearInterval(persistInterval)
|
||||
|
||||
log.log('persisting games...')
|
||||
GameStorage.persistGames()
|
||||
|
||||
log.log('shutting down webserver...')
|
||||
server.close()
|
||||
|
||||
log.log('shutting down websocketserver...')
|
||||
wss.close()
|
||||
|
||||
log.log('shutting down...')
|
||||
process.exit()
|
||||
}
|
||||
|
||||
// used by nodemon
|
||||
process.once('SIGUSR2', function () {
|
||||
gracefulShutdown('SIGUSR2')
|
||||
})
|
||||
|
||||
process.once('SIGINT', function (code) {
|
||||
gracefulShutdown('SIGINT')
|
||||
})
|
||||
|
||||
process.once('SIGTERM', function (code) {
|
||||
gracefulShutdown('SIGTERM')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue