lots of updates

This commit is contained in:
Neo 2025-03-08 00:25:19 -05:00
commit 50fb1d56f8
19 changed files with 29455 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
/tmp
/out-tsc
/node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/.pnp
.pnp.js
.vscode/*

8
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

6308
assets/blacks.txt Normal file

File diff suppressed because it is too large Load Diff

22440
assets/whites.txt Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "game-networking",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"start": "bun run src/index.ts"
},
"type": "module",
"dependencies": {
"@types/amqplib": "^0.10.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-ws": "^3.0.5",
"amqplib": "^0.10.5",
"bson": "^6.10.3",
"colors": "^1.4.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"ts-events": "^3.4.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"typescript": "^5.5.3"
},
"private": true
}

14
src/cards.ts Normal file
View File

@ -0,0 +1,14 @@
import fs from "fs"
async function parseCards(file: string) {
const fileData = fs.readFileSync(file).toString("utf8")
return fileData.split("\n")
}
export const blacks = (await parseCards("assets/blacks.txt")).filter(card => card.split("{{fill}}").length < 2)
export const whites = await parseCards("assets/whites.txt")
export function randomCard(cards: string[]) {
const randomCard = Math.floor(Math.random() * cards.length)
return cards[randomCard]
}

71
src/errors.ts Normal file
View File

@ -0,0 +1,71 @@
const UserAuthenticationErrorBase = 93000
const GenericErrorBase = 18000
const GameErrorBase = 12000
export const GenericErrorCodes = {
MissingField: GenericErrorBase,
RouteDoesNotSupportMethod: GenericErrorBase + 1,
}
export const GenericErrors = {
MissingRequiredFields: createErrorBuilder(GenericErrorCodes.MissingField, "Required fields not found"),
RouteDoesNotSupportMethod: createError(GenericErrorCodes.RouteDoesNotSupportMethod, "Unsupported method on route")
}
export const GameErrorCodes = {
}
export const GameErrors = {
}
export const UserAuthenticationErrorCodes = {
}
export const UserAuthenticationErrors = {
}
type ErrorDetail = {
code: number,
message: string,
for?: string,
possibleFix?: string
}
class ErrorBuilder {
code: number
message: string
details: {
[key: string]: ErrorDetail
} | null = null
constructor(code: number, baseMessage: string) {
this.code = code
this.message = baseMessage
}
with(name: string, detail: ErrorDetail) {
if (this.details == null) {
this.details = {}
}
this.details[name] = detail
return this
}
build() {
return {
message: this.message,
code: this.code,
details: this.details
}
}
}
export function createErrorBuilder(code: number, baseMessage: string) {
return () => new ErrorBuilder(code, baseMessage)
}
export function createError(code: number, message: string) {
return { code, message }
}

23
src/hostManager.ts Normal file
View File

@ -0,0 +1,23 @@
import {v4} from "uuid";
import {Logger} from "./logger";
export class HostManager {
logger = Logger.get("HostManager");
activeCodes: Map<string, string> = new Map();
get nextCode() {
let code = ""
let pos = Math.floor(Math.random() * 12)
while (this.activeCodes.has((code = v4().replace(/-+/gm, "").slice(pos, pos + 4)))) {
this.logger.debug(`Code ${code} already in use. Trying next one`)
}
return code
}
removeCode(code: string) {
this.activeCodes.delete(code);
}
}
export const hostManager = new HostManager();

95
src/index.ts Normal file
View File

@ -0,0 +1,95 @@
import express, { Router } from "express"
import { ILayer } from "express-serve-static-core"
import color from "colors"
import cors from "cors"
import {v1} from "./routes/v1";
import {Logger} from "./logger";
import {GenericErrors} from "./errors";
import {configDotenv} from "dotenv";
import expressWs from "express-ws";
import {joinGameInstance, startGameServer} from "./routes/v1/gameServer";
configDotenv()
const Global = Logger.get("App")
async function main() {
const app = express() as unknown as expressWs.Application;
expressWs(app)
app.use(express.json())
app.use(cors({
origin: "*"
}))
let start = performance.now()
Global.wait("Building Application")
let router = Router()
router.use("/api/v1", v1)
router.use("/api", v1)
app.use(router)
app.ws("/live/start-game", startGameServer)
app.ws("/live/join-game/:channel", joinGameInstance)
Global.info(`Built Application in ${(performance.now() - start).toPrecision(2)}ms`)
function recursivePath(layer: ILayer, out: [string, string][], base: string) {
if (layer.name == "router") {
(layer.handle as Router).stack.forEach(l => recursivePath(l, out, base + layer.regexp.toString()
.replace('/^', '')
.replace('?(?=\\/|$)/i', '')
.replace(/\\\//g, '/')
.replace('(?:/(?=$))', '')))
} else if (layer.name == "bound dispatch") {
for (let key of Object.keys((layer.route! as any).methods)) {
out.push([key, (base + layer.route?.path).replace(/\/+/g, "/")])
}
}
}
let out: [string, string][] = []
app._router.stack.forEach((l: ILayer) => recursivePath(l, out, "/"))
function colorMethod(method: string) {
switch (method) {
case "get":
return color.green
case "post":
return color.yellow
case "put":
return color.blue
case "patch":
return color.magenta
case "delete":
return color.red
}
return color.stripColors
}
console.log()
Global.debug("Routes:\n")
out.forEach((route) => {
console.log(colorMethod(route[0])(`${"[App Router]".cyan} [${route[0].toUpperCase()}] ${route[1]}`))
app.all(route[1], (req, res) => {
res.status(400)
.json(GenericErrors.RouteDoesNotSupportMethod)
.end()
})
})
console.log()
Global.wait("Starting Application")
app.listen(8080, () => {
Global.info("Application successfully started.")
Global.info("Started on http://localhost:8080")
})
}
main().then()

53
src/logger.ts Normal file
View File

@ -0,0 +1,53 @@
import colors from "colors"
const LogLevel: Record<string, (str: string) => string> = {
Error: (str: string) => colors.bgRed(colors.black(str)),
Info: (str: string) => colors.green(str),
Debug: (str: string) => colors.cyan(str),
Warn: (str: string) => colors.bgYellow.black(str),
Wait: (str: string) => colors.gray(str)
}
export class Logger {
protected constructor(name: string, opts?: {
debug?: boolean
}) {
this._name = name
this._debug = opts?.debug ?? true
}
private _print(level: string, ...args: any[]) {
console.log(`[${new Date().toLocaleString(Intl.DateTimeFormat().resolvedOptions().locale, {
hour12: false,
dateStyle: "medium",
timeStyle: "long"
})}] ${LogLevel[level](`[${this._name} / ${level}]`)} ->`, ...args.map(v => typeof v === "string" ? LogLevel[level](v) : v))
}
info(...args: string[]) {
this._print("Info", ...args)
}
wait(...args: string[]) {
this._print("Wait", ...args)
}
error(...args: any[]) {
this._print("Error", ...args)
}
debug(...args: any[]) {
if (this._debug) this._print("Debug", ...args)
}
warn(...args: any[]) {
this._print("Warn", ...args)
}
private readonly _name: string
private _debug: boolean
static get(name: string) {
return new Logger(name)
}
}

35
src/requiredElements.ts Normal file
View File

@ -0,0 +1,35 @@
import {NextFunction, Response, Request} from "express";
import {GenericErrorCodes, GenericErrors} from "./errors";
export const requiredElements = (elements: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
let missing: string[] = []
elements.forEach((el) => {
let path = el.split(".")
let idx = req.body
for (let pathItem of path) {
if (idx[pathItem] != null) {
idx = idx[pathItem]
} else {
missing.push(el)
break
}
}
})
if (missing.length > 0) {
let requirementError = GenericErrors.MissingRequiredFields()
missing.forEach((el) => {
requirementError.with(el, {
code: GenericErrorCodes.MissingField,
message: `Missing field ${el}`,
possibleFix: "Include this field in the request",
})
})
res.status(400)
.json(requirementError.build())
.end()
} else {
next()
}
}
}

36
src/routes/v1.ts Normal file
View File

@ -0,0 +1,36 @@
import {Router} from "express";
import {requiredElements} from "../requiredElements";
import {gameRelations, startGameServer} from "./v1/gameServer";
import expressWs from "express-ws";
import {blacks, whites} from "../cards";
export const v1 = Router();
// game
{
v1.get("/pick-card/:color", (req, res) => {
const color = req.params.color;
switch (color) {
case "black":
res.json(blacks)
return
case "white":
res.json(whites)
return
}
})
v1.get("/play/:code", (req, res) => {
if (!gameRelations.has(req.params.code.toLowerCase())) {
res.status(400)
.json({
message: "Code not found"
})
return
}
res.json({
channel: gameRelations.get(req.params.code.toLowerCase()),
})
})
}

132
src/routes/v1/gameServer.ts Normal file
View File

@ -0,0 +1,132 @@
import {NextFunction, Request, Response} from "express";
import amqp from "amqplib";
import { WebSocket } from "ws"
import {BSON, serialize} from "bson"
import {hostManager} from "../../hostManager";
import {createPacket, GameConnectionHash, handlePacket, HandlerFunc, Packet} from "../../types/socket";
import {v4} from "uuid";
import {SyncEvent} from "ts-events";
export const gameRelations = new Map<string, string>();
type Event = {
channel: string,
data: any
}
const toClient = new SyncEvent<Event>()
const toServer = new SyncEvent<Event>()
export async function startGameServer(ws: WebSocket, req: Request) {
const handlers: Map<string, HandlerFunc> = new Map()
const myCode = hostManager.nextCode
let messageChannel = "";
function toServerHandler(ev: Event) {
console.log(ev)
if (ev.channel == messageChannel) {
ws.send(BSON.serialize(ev.data))
}
}
handlers.set("cfg.gameConnectionHash", async (data: GameConnectionHash, send) => {
messageChannel = `humanityBox.live.${data.hash}`
console.log(`Queue Created:`, messageChannel)
gameRelations.set(myCode, messageChannel)
toServer.attach(toServerHandler)
})
ws.send(BSON.serialize(createPacket("cfg.gameSecrets", {
gameCode: myCode,
})))
ws.on("message", async (msg: Buffer) => {
console.log(msg)
const data = BSON.deserialize(new Uint8Array(msg.buffer));
if (messageChannel.length > 0) {
console.log(`publishing to clients on ${messageChannel}: `, data);
toClient.post({
channel: messageChannel,
data: data
})
}
handlePacket(handlers, data, (data) => {
ws.send(BSON.serialize(data))
})
})
ws.on("close", () => {
hostManager.removeCode(myCode);
toServer.detach(toServerHandler)
toClient.post({
channel: messageChannel,
data: createPacket("game.forceQuit", {
reason: "Host disconnected"
})
})
})
}
export async function joinGameInstance(ws: WebSocket, req: Request){
const handlers: Map<string, HandlerFunc> = new Map()
const messageChannel = req.params.channel
if (![...gameRelations.values()].includes(messageChannel)) {
ws.send(serialize(createPacket("game.forceQuit", {
reason: "Game no longer exists"
})))
}
let uuid = ""
const connectionId = v4()
const toClientHandler = (msg: Event) => {
if (msg.channel != messageChannel) return;
if (!msg) return;
const data = msg.data
if (data.conn == undefined || (data.conn && data.conn == connectionId)) {
console.log(`for me! - ${connectionId} ${data.conn}`)
ws.send(BSON.serialize(msg.data))
}
}
toClient.attach(toClientHandler)
handlers.set("game.playerJoin", (data) => {
uuid = data.uuid
console.log(uuid)
})
ws.on("message", async (msg: Buffer) => {
// BSON: const data = BSON.deserialize(new Uint8Array(msg.buffer)) as Packet;
// JSON:
try {
const data = BSON.deserialize(msg) as Packet
data.conn = connectionId;
toServer.post({
channel: messageChannel,
data: data
})
} finally {
console.log("invalid data received")
}
})
ws.on("close", () => {
toClient.detach(toClientHandler)
console.log(connectionId)
toServer.post({
channel: messageChannel,
data: createPacket("game.playerLeave", {
uuid,
}, connectionId)
})
})
}

121
src/testCli.ts Normal file
View File

@ -0,0 +1,121 @@
import amqp from "amqplib";
import * as readline from "node:readline";
type AssertQueue = amqp.Replies.AssertQueue;
import {BSON} from "bson";
import {createPacket} from "./types/socket";
const amqpConn = await amqp.connect("amqp://localhost")
const chan = await amqpConn.createChannel()
const exch = await chan.assertExchange("humanitybox.live", "direct")
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
let closed = false
const commands: Map<string, (args: string[]) => void> = new Map()
commands.set("close", () => {
closed = true
})
let queue: AssertQueue | null = null
const variables: Map<string, string> = new Map()
commands.set("connect", async (args) => {
const gameHash = args.shift()
queue = await chan.assertQueue(`humanityBox.live.${gameHash}`, {
autoDelete: true
})
chan.bindQueue(queue.queue, exch.exchange, "")
})
commands.set("set", (args) => {
const variable = args.shift()!
const value = args.join(" ")
variables.set(variable, value)
})
function send(data: any) {
if (!queue) {
console.log("Queue not connected. Please connect before sending")
return
}
chan.sendToQueue(queue.queue, Buffer.from(BSON.serialize(data)))
}
commands.set("join", () => {
if (!variables.has("playerName")) {
console.log("playerName is missing. Please set playerName")
return
}
send(createPacket("game.playerJoin", {
playerName: variables.get("playerName"),
}))
})
commands.set("leave", () => {
if (!variables.has("playerName")) {
console.log("playerName is missing. Please set playerName")
return
}
send(createPacket("game.playerLeave", {
playerName: variables.get("playerName"),
}))
})
commands.set("start", () => {
send(createPacket("vip.startGame", {}))
})
commands.set("playCard", (args) => {
const card = args.join(" ")
if (!variables.has("uuid")) {
console.log("Variable uuid is missing. Please set uuid to run this command")
}
send(createPacket("game.action.playCard", {
uuid: variables.get("uuid"),
cardValue: card
}))
})
commands.set("pickCard", (args) => {
const card = args.shift()
if (!variables.has("uuid")) {
console.log("Variable uuid is missing. Please set uuid to run this command")
}
send(createPacket("game.action.pickCard", {
uuid: variables.get("uuid"),
picked: card
}))
})
function promptCommand() {
rl.question("cmd: ", (ans) => {
const args = ans.trim().split(" ")
const cmd = args.shift()
if (commands.has(cmd)) {
commands.get(cmd ?? "")?.call(null, args)
} else {
console.log(`Command ${cmd} not found`)
}
if (!closed) {
promptCommand()
} else {
send(createPacket("game.playerLeave", {
playerName: variables.get("playerName"),
}))
chan.close()
rl.close()
}
})
}
promptCommand()

View File

@ -0,0 +1,18 @@
export type StartGameRequest = {}
export type StartGameResponse = {
gameHash: string,
gameCode: string,
}
export type ConfirmJoinRequest = {
gameCode: string
}
export type ConfirmJoinResponse = {
canJoin: boolean,
info?: {
gameCode: string,
gameHash: string
}
}

1
src/types/index.ts Normal file
View File

@ -0,0 +1 @@
export * from "./game-instances"

41
src/types/socket.ts Normal file
View File

@ -0,0 +1,41 @@
export function createPacket(op: string, data: object, conn?: string) {
return {
op, data, conn
}
}
type GameSecretsCfg = {
gameCode: string
}
type GamePlayerJoin = {
playerName: string
}
type GamePlayerLeave = {
uuid: string
}
export type SendFunction = (data: any, conn?: string) => void;
export type HandlerFunc = (data: any, sock: SendFunction) => void;
export type Packet = {
op?: string,
data?: object,
conn?: string,
}
export function handlePacket(handlers: Map<string, HandlerFunc>, data: Packet, send: SendFunction) {
if (data.op && data.data) {
let handler: HandlerFunc | undefined;
if ((handler = handlers.get(data.op)) != undefined) {
handler(data.data, send)
}
}
}
type GameConnectionHash = {
hash: string
}
export type {GameSecretsCfg, GameConnectionHash, GamePlayerJoin, GamePlayerLeave }

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"esModuleInterop": true,
"moduleResolution": "Node",
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src"]
}