Compare commits

...

104 Commits

Author SHA1 Message Date
fc92d9ea79 Updated create and update calls to services 2024-11-17 20:59:24 +01:00
2e267a36aa #237 - Changed Prisma find calls to repos 2024-11-17 19:48:00 +01:00
6ee8bb8334 Minor improvements for char. type management 2024-11-17 18:00:58 +01:00
ec3bf0f51e Moved models to simplify and improve prisma schemas , added logic to update characterTypes 2024-11-17 17:53:25 +01:00
27f8bc8784 Implemented ZoneManager logics in teleport to make sure chars. are removed / added correctly 2024-11-16 23:12:37 +01:00
3185c478a6 Merge branch 'main' of ssh://gitea.directonline.io:29417/sylvan-quest/server 2024-11-16 01:21:35 +01:00
72ef04d683 Rm. comment. 2024-11-16 01:21:32 +01:00
446e8fa617 #237 Convert prisma finds to repos for time and data 2024-11-15 21:58:08 +01:00
f7072acdd2 Added code comment for #246 2024-11-15 01:58:05 +01:00
fda8cc532e #154: Remove tiles, event tiles, and objects that are out of the zone's width and height before saving zone 2024-11-14 23:46:26 +01:00
821e742527 Bug fix 2024-11-14 21:03:08 +01:00
460308d555 #161: Set default value for createdAt field, store zone chats into database 2024-11-14 20:45:37 +01:00
3f8f8745eb #161: Set default value for createdAt field, store zone chats into database 2024-11-14 20:43:40 +01:00
bf7f585270 Path walk improvements 2024-11-13 17:02:16 +01:00
fee4277b4f Removed emit 2024-11-13 16:10:41 +01:00
ffc07b7403 Send title with connect error message 2024-11-13 16:07:17 +01:00
344ddbaf39 #213: Prevent character:connect if user is already logged in from another character 2024-11-13 16:06:31 +01:00
86ed3ae4b0 Minor improvements 2024-11-13 15:34:43 +01:00
719c75616e CORS fix 2024-11-13 13:35:41 +01:00
cf954979c5 Improved logging some more 2024-11-13 13:27:44 +01:00
01ed1bce29 #233: Replaced all console logs, thrown errors with logger API 2024-11-13 13:25:03 +01:00
d4e0cbe398 #174: Refactor character manager into zoneManager for better DX, major refactor of time and weather system (data is stored in DB now instead of JSON file), npm update, npm format, many other improvements 2024-11-13 13:21:01 +01:00
628b3bf1fa npm update 2024-11-05 23:17:59 +01:00
709d34d59b npm run format 2024-11-05 23:16:18 +01:00
c4a42066ab #184: Added commands to toggle rain / fog, command bug fix, minor improvements 2024-11-05 23:15:56 +01:00
26dbaa45a7 #184: Allow GMs to set time 2024-11-05 23:07:23 +01:00
ae0241fecb #184: Added weather manager that periodically changes the weather in-game and emits this to players 2024-11-05 22:45:57 +01:00
3a566dae5a CORS fix 2024-11-05 21:48:55 +01:00
ad4f33676f Bug fix for reset password 2024-11-05 20:32:27 +01:00
44cfbd6ee8 Improved error message to client for password reset request, delete PasswordTokenReset after changing password 2024-11-05 01:00:11 +01:00
881e3375ab Better naming 2024-11-05 00:37:38 +01:00
6a76c4797a Added extra logging to HTTP endpoints 2024-11-05 00:33:12 +01:00
bf75ad001b Typo 2024-11-05 00:28:27 +01:00
3b473e5826 Added client URL to .env.example and cors 2024-11-05 00:27:09 +01:00
929a36554a Cors update 2024-11-05 00:19:02 +01:00
3fbc5f4e87 -_- 2024-11-05 00:15:20 +01:00
1526e0947a Added urlToken to Zod schema 2024-11-04 22:36:00 +01:00
7ec4303b40 npm format 2024-11-04 01:05:21 +01:00
f475b69022 npm update 2024-11-03 22:35:33 +01:00
27d8c7cff6 Finish password reset (hopefully) 2024-11-03 21:54:54 +01:00
b9a7f9aa8e Typo 2024-11-03 01:33:43 +01:00
5b6b968541 Merge remote-tracking branch 'origin/feature/#182-reset-password' 2024-11-03 01:27:46 +01:00
93abf4b631 Updated token hash, use repo instead of prisma for data fetching 2024-11-03 00:50:00 +01:00
0de574b9e1 npm update 2024-11-03 00:33:06 +01:00
c04c52aed0 Merge remote-tracking branch 'origin/main' into feature/#182-reset-password
# Conflicts:
#	src/utilities/http.ts
2024-11-02 21:43:25 +01:00
3f19730bd8 Added param to JSdoc 2024-11-02 21:26:47 +01:00
d0e3c95bb0 npm update 2024-11-02 02:18:17 +01:00
82f51b2b7e Added pw token expiry check, temporarily commented mailer code due to bugs 2024-11-02 01:46:50 +01:00
1b9db64854 npm update 2024-10-31 12:31:40 +01:00
41c71d5964 Added list_sprite_actions http endpoint 2024-10-30 15:26:39 +01:00
bd04dc2ab8 Continuation dynamic asset loading 2024-10-30 09:34:07 +01:00
a4e96f9ede (WIP) Added pw reset token row, added checks to reset function 2024-10-29 22:49:21 +01:00
f6bac403a2 Minor changes 2024-10-28 23:41:54 +01:00
8460d0b535 Worked on http endpoints for dynamic tile loading 2024-10-28 23:23:10 +01:00
5a36d10f0e Added reset password function + basic mail layout 2024-10-27 21:30:33 +01:00
8f8f019ab7 Add email field and add it to register logic 2024-10-27 17:25:45 +01:00
6a1823586a Commented out http endpoint 2024-10-26 02:41:41 +02:00
9d08073fa8 npm update, http asset endpoint changes 2024-10-25 22:21:06 +02:00
5631930bf5 Typo fix¿ 2024-10-21 19:13:20 +02:00
b6e7a5d7fe Started working on Dexie support 2024-10-21 02:08:04 +02:00
63804336be Inform user about not meeting requirements upon character creation 2024-10-19 23:39:56 +02:00
0b62b4231b npm update 2024-10-19 21:15:26 +02:00
4e1e7d95ac Added logging, worked on character type management 2024-10-19 02:14:39 +02:00
d29420cbf3 ? 2024-10-18 23:56:53 +02:00
acc04daa27 New migration 2024-10-18 23:24:43 +02:00
8abf5acef3 #137 : ZoneEffects 2024-10-18 23:08:50 +02:00
780cac9644 npm update 2024-10-18 00:21:38 +02:00
44481e19a8 npm update 2024-10-17 02:32:17 +02:00
9075bfaad5 npm update 2024-10-16 15:45:49 +02:00
bfd941c091 Rnamed Datetime > Date 2024-10-14 20:11:08 +02:00
bb9f62a9c8 Renamed files to storage, re-worked datetimeManager, added json help utilities 2024-10-14 19:47:52 +02:00
049b9de2b3 Renamed utilities to files, added datetimeManager, npm update 2024-10-13 12:15:29 +02:00
2008646a3f npm update 2024-10-04 20:06:09 +02:00
075592702c Zone editor bug fix: set updatedAt at saving so it gets sent back to the client 2024-10-02 19:57:08 +02:00
d271efc1ec npm update 2024-10-02 16:17:43 +02:00
297d4742a4 #91 : Zone editor: allow objects to be rotated 2024-10-01 21:59:51 +02:00
ab649b9fa1 Removed debugging line 2024-10-01 00:34:58 +02:00
4f643269eb Replace fix for tiles command 2024-10-01 00:30:25 +02:00
ce1708a55e Path fixes for all environments, npm run format, removed redundant imports 2024-10-01 00:10:30 +02:00
4cbd62cbb0 Test #1 2024-09-30 23:31:00 +02:00
7b3c4b92a5 File loading fixes for prod. 2024-09-30 23:18:27 +02:00
da8ef9fa65 Uhm excuse me, but what the fuck 2024-09-30 22:56:17 +02:00
4f9a1bc879 npm run dev 2024-09-30 22:42:46 +02:00
3638e2a793 Fixed oopsies 2024-09-30 22:39:48 +02:00
6ac827630a Update command manager and commands to OOP 2024-09-30 22:29:58 +02:00
3ec4bc2557 Prod. command fix attempt #2 2024-09-30 22:20:07 +02:00
6a286590b4 Fix maybe 2024-09-30 22:17:14 +02:00
34ed2ba7cb TMUX? 2024-09-30 21:23:38 +02:00
72159cdc17 Add screen 2024-09-30 21:14:19 +02:00
70d8c43350 #169 : Fixed command for tile bleeding 2024-09-30 20:22:41 +02:00
3a83f2c1ff #169 : Re-enabled command manager, created extrudeTiles command for testing 2024-09-30 19:02:55 +02:00
ddeee356b4 Revert "#169 : Expand tiles to 68x64px and draw 1px line to each side-profile to"
This reverts commit e9fb277d63.
2024-09-29 21:56:03 +02:00
e9fb277d63 #169 : Expand tiles to 68x64px and draw 1px line to each side-profile to 2024-09-29 21:23:16 +02:00
e8aee51248 #95 : Replaced : with / for commands 2024-09-28 21:26:27 +02:00
46fdb3edb6 #160 : Fix for being unable to chat after teleporting into another zone 2024-09-28 03:17:15 +02:00
dec6b36699 #143 : Fix switching back to zone from zoneEditor 2024-09-28 02:52:16 +02:00
21a75f6cbe npm run format 2024-09-28 02:18:31 +02:00
10a231b54c Log when a character joins a zone 2024-09-28 02:18:06 +02:00
cc9eada654 Send frameCount for assets to client 2024-09-28 00:59:20 +02:00
6f057639c0 npm update 2024-09-28 00:39:05 +02:00
251a72aa97 npm update 2024-09-26 00:42:38 +02:00
0d7ed18b03 npm update 2024-09-24 16:09:40 +02:00
4a9b7987dc Added option to set rotation on teleport tiles, new base database migration (db reset needed) 2024-09-23 14:02:25 +02:00
a729371741 Converted socketEvents to new format
Still need to rework 'any' type Promises
2024-09-22 18:35:07 +02:00
93 changed files with 2576 additions and 1223 deletions

View File

@ -4,6 +4,7 @@ PORT=4000
DATABASE_URL="mysql://root@localhost:3306/nq"
REDIS_URL="redis://@127.0.0.1:6379/4"
JWT_SECRET="secret"
CLIENT_URL="http://localhost:5173"
# Game configuration
ALLOW_DIAGONAL_MOVEMENT=false
@ -11,4 +12,10 @@ ALLOW_DIAGONAL_MOVEMENT=false
# Default character create values
DEFAULT_CHARACTER_ZONE="0"
DEFAULT_CHARACTER_POS_X="0"
DEFAULT_CHARACTER_POS_Y="0"
DEFAULT_CHARACTER_POS_Y="0"
# SMTP configuration
SMTP_HOST="my.directonline.io"
SMTP_PORT="587"
SMTP_USER="no-reply@sylvan.quest"
SMTP_PASSWORD=""

5
.gitignore vendored
View File

@ -309,7 +309,4 @@ $RECYCLE.BIN/
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,macos,windows
prisma/dev.db
prisma/dev.db-journal
# End of https://www.toptal.com/developers/gitignore/api/node,jetbrains+all,visualstudiocode,macos,windows

View File

@ -1,8 +1,8 @@
# Use the official Node.js 22.4.1 image
FROM node:22.4.1-alpine
# Install Redis
RUN apk add --no-cache redis
# Install Redis and tmux
RUN apk add --no-cache redis tmux
# Set the working directory in the container
WORKDIR /usr/src/
@ -28,11 +28,13 @@ RUN npm run build
# Expose the ports your Node.js application and Redis will listen on
EXPOSE 80 6379
# Create a shell script to run Redis, run migrations, and start the application
# Create a shell script to run Redis, run migrations, and start the application in a tmux session
RUN echo '#!/bin/sh' > /usr/src/start.sh && \
echo 'redis-server --daemonize yes' >> /usr/src/start.sh && \
echo 'npx prisma migrate deploy' >> /usr/src/start.sh && \
echo 'node dist/server.js' >> /usr/src/start.sh && \
echo 'tmux new-session -d -s nodeapp "node dist/server.js"' >> /usr/src/start.sh && \
echo 'echo "App is running in tmux session. Attach with: tmux attach-session -t nodeapp"' >> /usr/src/start.sh && \
echo 'tail -f /dev/null' >> /usr/src/start.sh && \
chmod +x /usr/src/start.sh
# Use the shell script as the entry point

347
package-lock.json generated
View File

@ -14,11 +14,11 @@
"express": "^4.19.2",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.15",
"pino": "^9.3.2",
"prisma": "^5.17.0",
"sharp": "^0.33.4",
"socket.io": "^4.7.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.3",
"zod": "^3.23.8"
},
@ -27,14 +27,17 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.11",
"@types/nodemailer": "^6.4.16",
"nodemon": "^3.1.4",
"prettier": "^3.3.3"
"prettier": "^3.3.3",
"ts-node": "^10.9.2"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
@ -44,9 +47,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz",
"integrity": "sha512-bV21/9LQmcQeCPEg3BDFtvwL6cwiTMksYNWQQ4KOxCZikEGalWtenoZ0wCiukJINlGCIi2KXx01g4FoH/LxpzQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -424,6 +427,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@ -433,12 +437,14 @@
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
@ -524,9 +530,9 @@
]
},
"node_modules/@prisma/client": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.19.1.tgz",
"integrity": "sha512-x30GFguInsgt+4z5I4WbkZP2CGpotJMUXy+Gl/aaUjHn2o1DnLYNTA+q9XdYmAQZM8fIIkvUiA2NpgosM3fneg==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
@ -542,48 +548,48 @@
}
},
"node_modules/@prisma/debug": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.19.1.tgz",
"integrity": "sha512-lAG6A6QnG2AskAukIEucYJZxxcSqKsMK74ZFVfCTOM/7UiyJQi48v6TQ47d6qKG3LbMslqOvnTX25dj/qvclGg==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.19.1.tgz",
"integrity": "sha512-kR/PoxZDrfUmbbXqqb8SlBBgCjvGaJYMCOe189PEYzq9rKqitQ2fvT/VJ8PDSe8tTNxhc2KzsCfCAL+Iwm/7Cg==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.1",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"@prisma/fetch-engine": "5.19.1",
"@prisma/get-platform": "5.19.1"
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3.tgz",
"integrity": "sha512-xR6rt+z5LnNqTP5BBc+8+ySgf4WNMimOKXRn6xfNRDSpHvbOEmd7+qAOmzCrddEc4Cp8nFC0txU14dstjH7FXA==",
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.19.1.tgz",
"integrity": "sha512-pCq74rtlOVJfn4pLmdJj+eI4P7w2dugOnnTXpRilP/6n5b2aZiA4ulJlE0ddCbTPkfHmOL9BfaRgA8o+1rfdHw==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.1",
"@prisma/engines-version": "5.19.1-2.69d742ee20b815d88e17e54db4a2a7a3b30324e3",
"@prisma/get-platform": "5.19.1"
"@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.22.0"
}
},
"node_modules/@prisma/get-platform": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.19.1.tgz",
"integrity": "sha512-sCeoJ+7yt0UjnR+AXZL7vXlg5eNxaFOwC23h0KvW1YIXUoa7+W2ZcAUhoEQBmJTW4GrFqCuZ8YSP0mkDa4k3Zg==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "5.19.1"
"@prisma/debug": "5.22.0"
}
},
"node_modules/@socket.io/component-emitter": {
@ -596,24 +602,28 @@
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/bcryptjs": {
@ -673,9 +683,9 @@
}
},
"node_modules/@types/express-serve-static-core": {
"version": "4.19.5",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.5.tgz",
"integrity": "sha512-y6W03tvrACO72aijJ5uF02FRq5cgDR9lUxddQ8vyF+GvmjJQqbzDcJngEjURc+ZsG31VI3hODNZJ2URj86pzmg==",
"version": "4.19.6",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz",
"integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -719,18 +729,28 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.16.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.5.tgz",
"integrity": "sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==",
"version": "20.17.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz",
"integrity": "sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.16",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.16.tgz",
"integrity": "sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.9.16",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz",
"integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==",
"version": "6.9.17",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
"integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==",
"dev": true,
"license": "MIT"
},
@ -764,18 +784,6 @@
"@types/send": "*"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@ -790,9 +798,10 @@
}
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@ -805,6 +814,7 @@
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
@ -831,6 +841,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/array-flatten": {
@ -855,26 +866,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@ -951,30 +942,6 @@
"node": ">=8"
}
},
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@ -982,14 +949,14 @@
"license": "BSD-3-Clause"
},
"node_modules/bullmq": {
"version": "5.13.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.13.2.tgz",
"integrity": "sha512-McGE8k3mrCvdUHdU0sHkTKDS1xr4pff+hbEKBY51wk5S6Za0gkuejYA620VQTo3Zz37E/NVWMgumwiXPQ3yZcA==",
"version": "5.26.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.26.2.tgz",
"integrity": "sha512-UdHBrJoRkpXoF8b/FVEkuRBnaUZoA7+qHQNyTx1n2oNVZ4iWxqGqss+M9xAwXOpBmSNvOSlaBdHpf+5QJTU8GQ==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.6.0",
"ioredis": "^5.4.1",
"msgpackr": "^1.10.1",
"msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1",
"semver": "^7.5.4",
"tslib": "^2.0.0",
@ -1128,9 +1095,9 @@
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -1159,6 +1126,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cron-parser": {
@ -1240,6 +1208,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@ -1282,9 +1251,9 @@
}
},
"node_modules/engine.io": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.1.tgz",
"integrity": "sha512-NEpDCw9hrvBW+hVEOK4T7v0jFJ++KgtPl4jKFwsZVfG1XhS0dCrSb3VMb9gPAd7VAdW52VT1EnaNiU2vM8C0og==",
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz",
"integrity": "sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.4.1",
@ -1292,7 +1261,7 @@
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.4.1",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
@ -1312,9 +1281,9 @@
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
"integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==",
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -1379,28 +1348,10 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/express": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz",
"integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==",
"version": "4.21.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@ -1408,7 +1359,7 @@
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.6.0",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
@ -1650,26 +1601,6 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore-by-default": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
@ -1907,6 +1838,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/media-typer": {
@ -1989,9 +1921,9 @@
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz",
"integrity": "sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz",
"integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
@ -2049,6 +1981,15 @@
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "6.9.16",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz",
"integrity": "sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz",
@ -2123,9 +2064,9 @@
}
},
"node_modules/object-inspect": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz",
"integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==",
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
"integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -2184,15 +2125,15 @@
}
},
"node_modules/pino": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.4.0.tgz",
"integrity": "sha512-nbkQb5+9YPhQRz/BeQmrWpEknAaqjpAqRK8NwJpmrX/JHu7JuZC5G1CeAwJDJfGes4h+YihC6in3Q2nGb+Y09w==",
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz",
"integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0",
"fast-redact": "^3.1.1",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^1.2.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^4.0.0",
"quick-format-unescaped": "^4.0.3",
@ -2206,12 +2147,11 @@
}
},
"node_modules/pino-abstract-transport": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz",
"integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"readable-stream": "^4.0.0",
"split2": "^4.0.0"
}
},
@ -2238,13 +2178,13 @@
}
},
"node_modules/prisma": {
"version": "5.19.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.19.1.tgz",
"integrity": "sha512-c5K9MiDaa+VAAyh1OiYk76PXOme9s3E992D7kvvIOhCrNsBQfy2mP2QAQtX0WNj140IgG++12kwZpYB9iIydNQ==",
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/engines": "5.19.1"
"@prisma/engines": "5.22.0"
},
"bin": {
"prisma": "build/index.js"
@ -2256,15 +2196,6 @@
"fsevents": "2.3.3"
}
},
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/process-warning": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz",
@ -2336,22 +2267,6 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz",
"integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -2599,9 +2514,9 @@
}
},
"node_modules/socket.io": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz",
"integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==",
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
@ -2709,9 +2624,9 @@
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.1.0.tgz",
"integrity": "sha512-NGipjjRicyJJ03rPiZCJYjwlsuP2d1/5QUviozRXC7S3WdVWNK5e3Ojieb9CCyfhq2UC+3+SRd9nG3I2lPRvUw==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
@ -2741,15 +2656,6 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@ -2808,6 +2714,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
@ -2848,9 +2755,9 @@
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": {
@ -2867,9 +2774,9 @@
}
},
"node_modules/typescript": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -2927,6 +2834,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/vary": {
@ -2963,6 +2871,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"

View File

@ -1,7 +1,7 @@
{
"scripts": {
"start": "npx prisma migrate deploy && node dist/server.js",
"dev": "nodemon --exec ts-node src/server.ts",
"dev": "nodemon --ignore 'data/*' --exec ts-node src/server.ts",
"build": "tsc",
"format": "prettier --write src/"
},
@ -15,11 +15,11 @@
"express": "^4.19.2",
"ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.15",
"pino": "^9.3.2",
"prisma": "^5.17.0",
"sharp": "^0.33.4",
"socket.io": "^4.7.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.3",
"zod": "^3.23.8"
},
@ -28,6 +28,8 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.11",
"@types/nodemailer": "^6.4.16",
"ts-node": "^10.9.2",
"nodemon": "^3.1.4",
"prettier": "^3.3.3"
}

0
prisma/.gitignore vendored Normal file
View File

View File

@ -1,3 +1,14 @@
-- CreateTable
CREATE TABLE `World` (
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`isRainEnabled` BOOLEAN NOT NULL DEFAULT false,
`rainPercentage` INTEGER NOT NULL DEFAULT 0,
`isFogEnabled` BOOLEAN NOT NULL DEFAULT false,
`fogDensity` INTEGER NOT NULL DEFAULT 0,
UNIQUE INDEX `World_date_key`(`date`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Chat` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
@ -40,10 +51,23 @@ CREATE TABLE `SpriteAction` (
CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`online` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `User_username_key`(`username`),
UNIQUE INDEX `User_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `PasswordResetToken` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`token` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `PasswordResetToken_token_key`(`token`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@ -53,7 +77,7 @@ CREATE TABLE `CharacterType` (
`name` VARCHAR(191) NOT NULL,
`gender` ENUM('MALE', 'FEMALE') NOT NULL,
`race` ENUM('HUMAN', 'ELF', 'DWARF', 'ORC', 'GOBLIN') NOT NULL,
`spriteId` VARCHAR(191) NOT NULL,
`spriteId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
@ -146,12 +170,23 @@ CREATE TABLE `Zone` (
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ZoneEffect` (
`id` VARCHAR(191) NOT NULL,
`zoneId` INTEGER NOT NULL,
`effect` VARCHAR(191) NOT NULL,
`strength` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ZoneObject` (
`id` VARCHAR(191) NOT NULL,
`zoneId` INTEGER NOT NULL,
`objectId` VARCHAR(191) NOT NULL,
`depth` INTEGER NOT NULL DEFAULT 0,
`isRotated` BOOLEAN NOT NULL DEFAULT false,
`positionX` INTEGER NOT NULL DEFAULT 0,
`positionY` INTEGER NOT NULL DEFAULT 0,
@ -174,6 +209,7 @@ CREATE TABLE `ZoneEventTileTeleport` (
`id` VARCHAR(191) NOT NULL,
`zoneEventTileId` VARCHAR(191) NOT NULL,
`toZoneId` INTEGER NOT NULL,
`toRotation` INTEGER NOT NULL,
`toPositionX` INTEGER NOT NULL,
`toPositionY` INTEGER NOT NULL,
@ -190,6 +226,9 @@ ALTER TABLE `Chat` ADD CONSTRAINT `Chat_zoneId_fkey` FOREIGN KEY (`zoneId`) REFE
-- AddForeignKey
ALTER TABLE `SpriteAction` ADD CONSTRAINT `SpriteAction_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `PasswordResetToken` ADD CONSTRAINT `PasswordResetToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CharacterType` ADD CONSTRAINT `CharacterType_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@ -208,6 +247,9 @@ ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_characterId_fkey` FORE
-- AddForeignKey
ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_itemId_fkey` FOREIGN KEY (`itemId`) REFERENCES `Item`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ZoneEffect` ADD CONSTRAINT `ZoneEffect_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `ZoneObject` ADD CONSTRAINT `ZoneObject_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Chat` MODIFY `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3);

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `CharacterType` ADD COLUMN `isEnabledForCharCreation` BOOLEAN NOT NULL DEFAULT false;

View File

@ -1,3 +1,21 @@
model World {
date DateTime @unique @default(now())
isRainEnabled Boolean @default(false)
rainPercentage Int @default(0)
isFogEnabled Boolean @default(false)
fogDensity Int @default(0)
}
model Chat {
id Int @id @default(autoincrement())
characterId Int
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
message String
createdAt DateTime @default(now())
}
model Sprite {
id String @id @default(uuid())
name String
@ -13,8 +31,8 @@ model SpriteAction {
sprite Sprite @relation(fields: [spriteId], references: [id], onDelete: Cascade)
action String
sprites Json?
originX Decimal @default(0)
originY Decimal @default(0)
originX Decimal @default(0)
originY Decimal @default(0)
isAnimated Boolean @default(false)
isLooping Boolean @default(false)
frameWidth Int @default(0)

View File

@ -11,7 +11,7 @@
// npx prisma migrate deploy
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["prismaSchemaFolder"]
}
@ -19,13 +19,3 @@ datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Chat {
id Int @id @default(autoincrement())
characterId Int
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
message String
createdAt DateTime
}

View File

@ -1,9 +1,19 @@
model User {
id Int @id @default(autoincrement())
username String @unique
password String
online Boolean @default(false)
characters Character[]
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
online Boolean @default(false)
characters Character[]
passwordResetTokens PasswordResetToken[]
}
model PasswordResetToken {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
createdAt DateTime @default(now())
}
enum CharacterGender {
@ -20,15 +30,16 @@ enum CharacterRace {
}
model CharacterType {
id Int @id @default(autoincrement())
name String
gender CharacterGender
race CharacterRace
characters Character[]
spriteId String
sprite Sprite @relation(fields: [spriteId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
name String
gender CharacterGender
race CharacterRace
isEnabledForCharCreation Boolean @default(false)
characters Character[]
spriteId String?
sprite Sprite? @relation(fields: [spriteId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Character {

View File

@ -38,6 +38,7 @@ model Zone {
height Int @default(10)
tiles Json?
pvp Boolean @default(false)
zoneEffects ZoneEffect[]
zoneEventTiles ZoneEventTile[]
zoneEventTileTeleports ZoneEventTileTeleport[]
zoneObjects ZoneObject[]
@ -47,15 +48,24 @@ model Zone {
updatedAt DateTime @updatedAt
}
model ZoneEffect {
id String @id @default(uuid())
zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
effect String
strength Int
}
model ZoneObject {
id String @id @default(uuid())
id String @id @default(uuid())
zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
objectId String
object Object @relation(fields: [objectId], references: [id], onDelete: Cascade)
depth Int @default(0)
positionX Int @default(0)
positionY Int @default(0)
object Object @relation(fields: [objectId], references: [id], onDelete: Cascade)
depth Int @default(0)
isRotated Boolean @default(false)
positionX Int @default(0)
positionY Int @default(0)
}
enum ZoneEventTileType {
@ -81,6 +91,7 @@ model ZoneEventTileTeleport {
zoneEventTile ZoneEventTile @relation(fields: [zoneEventTileId], references: [id], onDelete: Cascade)
toZoneId Int
toZone Zone @relation(fields: [toZoneId], references: [id], onDelete: Cascade)
toRotation Int
toPositionX Int
toPositionY Int
}

View File

@ -2,8 +2,12 @@ import { Server } from 'socket.io'
type CommandInput = string[]
export default function (input: CommandInput, io: Server) {
const message: string = input.join(' ') ?? null
if (!message) return console.log('message is required')
io.emit('notification', { message: message })
export default class AlertCommand {
constructor(private readonly io: Server) {}
public execute(input: CommandInput): void {
const message: string = input.join(' ') ?? null
if (!message) return console.log('message is required')
this.io.emit('notification', { message: message })
}
}

View File

@ -3,6 +3,10 @@ import ZoneManager from '../managers/zoneManager'
type CommandInput = string[]
export default function (input: CommandInput, io: Server) {
console.log(ZoneManager.getLoadedZones())
export default class ListZonesCommand {
constructor(private readonly io: Server) {}
public execute(input: CommandInput): void {
console.log(ZoneManager.getLoadedZones())
}
}

58
src/commands/tiles.ts Normal file
View File

@ -0,0 +1,58 @@
import fs from 'fs'
import sharp from 'sharp'
import { commandLogger } from '../utilities/logger'
import { Server } from 'socket.io'
import { getPublicPath } from '../utilities/storage'
import path from 'path'
export default class TilesCommand {
constructor(private readonly io: Server) {}
public async execute(): Promise<void> {
// Get all tiles
const tilesDir = getPublicPath('tiles')
const tiles = fs.readdirSync(tilesDir).filter((file) => file.endsWith('.png'))
// Create output directory if it doesn't exist
if (!fs.existsSync(tilesDir)) {
fs.mkdirSync(tilesDir, { recursive: true })
}
for (const tile of tiles) {
// Check if tile is already 66x34
const metadata = await sharp(getPublicPath('tiles', tile)).metadata()
if (metadata.width === 66 && metadata.height === 34) {
commandLogger.info(`Tile ${tile} already processed`)
continue
}
const inputPath = getPublicPath('tiles', tile)
const tempPath = getPublicPath('tiles', `temp_${tile}`)
try {
await sharp(inputPath)
.resize({
width: 66,
height: 34,
fit: 'fill',
kernel: 'nearest'
})
.toFile(tempPath)
// Replace original file with processed file
fs.unlinkSync(inputPath)
fs.renameSync(tempPath, inputPath)
commandLogger.info(`Processed and replaced: ${tile}`)
} catch (error) {
console.error(`Error processing ${tile}:`, error)
// Clean up temp file if it exists
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath)
}
}
}
commandLogger.info('Tile processing completed.')
}
}

View File

@ -1,42 +0,0 @@
import { ExtendedCharacter, TSocket } from '../utilities/types'
import { Zone } from '@prisma/client'
import prisma from '../utilities/prisma'
class CharacterManager {
private characters!: ExtendedCharacter[]
public async boot() {
this.characters = []
}
public initCharacter(character: ExtendedCharacter) {
this.characters = [...this.characters, character]
}
public async removeCharacter(character: ExtendedCharacter) {
await prisma.character.update({
where: { id: character.id },
data: {
positionX: character.positionX,
positionY: character.positionY,
rotation: character.rotation,
zoneId: character.zoneId
}
})
this.characters = this.characters.filter((x) => x.id !== character.id)
}
public getCharacterFromSocket(socket: TSocket) {
return this.characters.find((x) => x.id === socket?.characterId)
}
public hasResetMovement(character: ExtendedCharacter) {
return this.characters.find((x) => x.id === character.id)?.resetMovement
}
public getCharactersInZone(zone: Zone) {
return this.characters.filter((x) => x.zoneId === zone.id)
}
}
export default new CharacterManager()

View File

@ -2,9 +2,11 @@ import * as readline from 'readline'
import * as fs from 'fs'
import * as path from 'path'
import { Server } from 'socket.io'
import { commandLogger } from '../utilities/logger'
import { getAppPath } from '../utilities/storage'
class CommandManager {
private commands: Map<string, Function> = new Map()
private commands: Map<string, any> = new Map()
private rl: readline.Interface
private io: Server | null = null
private rlClosed: boolean = false
@ -23,7 +25,7 @@ class CommandManager {
public async boot(io: Server) {
this.io = io
await this.loadCommands()
console.log('[✅] Command manager loaded')
commandLogger.info('Command manager loaded')
this.startPrompt()
}
@ -39,7 +41,9 @@ class CommandManager {
private async processCommand(command: string): Promise<void> {
const [cmd, ...args] = command.trim().split(' ')
if (this.commands.has(cmd)) {
this.commands.get(cmd)?.(args, this.io as Server)
const CommandClass = this.commands.get(cmd)
const commandInstance = new CommandClass(this.io as Server)
await commandInstance.execute(args)
} else {
this.handleUnknownCommand(cmd)
}
@ -48,7 +52,6 @@ class CommandManager {
private handleUnknownCommand(command: string) {
switch (command) {
case 'exit':
console.log('Goodbye!')
this.rl.close()
break
default:
@ -58,37 +61,43 @@ class CommandManager {
}
private async loadCommands() {
const commandsDir = path.resolve(__dirname, 'commands')
const directory = getAppPath('commands')
commandLogger.info(`Loading commands from: ${directory}`)
try {
const files: string[] = await fs.promises.readdir(commandsDir)
const files = await fs.promises.readdir(directory, { withFileTypes: true })
for (const file of files) {
await this.loadCommand(commandsDir, file)
if (!file.isFile() || (!file.name.endsWith('.ts') && !file.name.endsWith('.js'))) {
continue
}
const fullPath = getAppPath('commands', file.name)
const commandName = path.basename(file.name, path.extname(file.name))
try {
const module = await import(fullPath)
if (typeof module.default !== 'function') {
commandLogger.warn(`Unrecognized export in ${file.name}`)
continue
}
this.registerCommand(commandName, module.default)
} catch (error) {
commandLogger.error(`Error loading command ${file.name}: ${error instanceof Error ? error.message : String(error)}`)
}
}
} catch (error) {
console.error('[❌] Failed to read commands directory:', error)
commandLogger.error(`Failed to read commands directory: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async loadCommand(commandsDir: string, file: string) {
try {
const ext = path.extname(file)
const commandName = path.basename(file, ext)
const commandPath = path.join(commandsDir, file)
const module = await import(commandPath)
this.registerCommand(commandName, module.default)
} catch (error) {
console.error('[❌] Failed to load command:', file, error)
}
}
private registerCommand(name: string, command: (args: string[], io: Server) => void) {
private registerCommand(name: string, CommandClass: any) {
if (this.commands.has(name)) {
console.warn(`Command '${name}' is already registered. Overwriting...`)
commandLogger.warn(`Command '${name}' is already registered. Overwriting...`)
}
this.commands.set(name, command)
console.log(`Registered command: ${name}`)
this.commands.set(name, CommandClass)
commandLogger.info(`Registered command: ${name}`)
}
}

View File

@ -0,0 +1,96 @@
import { Server } from 'socket.io'
import { appLogger } from '../utilities/logger'
import worldService from '../services/worldService'
import worldRepository from '../repositories/worldRepository'
class DateManager {
private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours
private static readonly UPDATE_INTERVAL = 1000 // 1 second
private io: Server | null = null
private intervalId: NodeJS.Timeout | null = null
private currentDate: Date = new Date()
public async boot(io: Server): Promise<void> {
this.io = io
await this.loadDate()
this.startDateLoop()
appLogger.info('Date manager loaded')
}
public async setTime(time: string): Promise<void> {
try {
let newDate: Date
// Check if it's just a time (HH:mm or HH:mm:ss format)
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(time)) {
const [hours, minutes] = time.split(':').map(Number)
newDate = new Date(this.currentDate) // Clone current date
newDate.setHours(hours, minutes)
} else {
// Treat as full datetime string
newDate = new Date(time)
if (isNaN(newDate.getTime())) return
}
this.currentDate = newDate
this.emitDate()
await this.saveDate()
} catch (error) {
appLogger.error(`Failed to set time: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
private async loadDate(): Promise<void> {
try {
const world = await worldRepository.getFirst()
if (world) {
this.currentDate = world.date
}
} catch (error) {
appLogger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`)
this.currentDate = new Date() // Use current date as fallback
}
}
private startDateLoop(): void {
this.intervalId = setInterval(() => {
this.advanceGameTime()
this.emitDate()
void this.saveDate()
}, DateManager.UPDATE_INTERVAL)
}
private advanceGameTime(): void {
const advanceMilliseconds = DateManager.GAME_SPEED * DateManager.UPDATE_INTERVAL
this.currentDate = new Date(this.currentDate.getTime() + advanceMilliseconds)
}
private emitDate(): void {
this.io?.emit('date', this.currentDate)
}
private async saveDate(): Promise<void> {
try {
await worldService.update({
date: this.currentDate
})
} catch (error) {
appLogger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`)
}
}
public cleanup(): void {
if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public getCurrentDate(): Date {
return this.currentDate
}
}
export default new DateManager()

View File

@ -5,7 +5,7 @@ import { Server as SocketServer } from 'socket.io'
import { TSocket } from '../utilities/types'
import { queueLogger } from '../utilities/logger'
import fs from 'fs'
import path from 'path'
import { getAppPath } from '../utilities/storage'
class QueueManager {
private connection!: IORedis
@ -52,9 +52,9 @@ class QueueManager {
const { jobName, params, socketId } = job.data
try {
const jobsDir = path.join(process.cwd(), 'src', 'jobs')
const jobsDir = getAppPath('jobs')
const extension = config.ENV === 'development' ? '.ts' : '.js'
const jobPath = path.join(jobsDir, `${jobName}${extension}`)
const jobPath = getAppPath('jobs', `${jobName}${extension}`)
if (!fs.existsSync(jobPath)) {
queueLogger.warn(`Job file not found: ${jobPath}`)

View File

@ -0,0 +1,127 @@
import { Server } from 'socket.io'
import { appLogger } from '../utilities/logger'
import worldService from '../services/worldService'
import worldRepository from '../repositories/worldRepository'
interface WeatherState {
isRainEnabled: boolean
rainPercentage: number
isFogEnabled: boolean
fogDensity: number
}
class WeatherManager {
private static readonly UPDATE_INTERVAL = 60000 // Check weather every minute
private static readonly RAIN_CHANCE = 0.2 // 20% chance of rain
private static readonly FOG_CHANCE = 0.15 // 15% chance of fog
private io: Server | null = null
private intervalId: NodeJS.Timeout | null = null
private weatherState: WeatherState = {
isRainEnabled: false,
rainPercentage: 0,
isFogEnabled: false,
fogDensity: 0
}
public async boot(io: Server): Promise<void> {
this.io = io
await this.loadWeather()
this.startWeatherLoop()
appLogger.info('Weather manager loaded')
}
public async toggleRain(): Promise<void> {
this.weatherState.isRainEnabled = !this.weatherState.isRainEnabled
this.weatherState.rainPercentage = this.weatherState.isRainEnabled
? Math.floor(Math.random() * 50) + 50 // 50-100%
: 0
await this.saveWeather()
this.emitWeather()
}
public async toggleFog(): Promise<void> {
this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled
this.weatherState.fogDensity = this.weatherState.isFogEnabled
? Math.floor((Math.random() * 0.7 + 0.3) * 100) // Convert 0.3-1.0 to 30-100
: 0
await this.saveWeather()
this.emitWeather()
}
private async loadWeather(): Promise<void> {
try {
const world = await worldRepository.getFirst()
if (world) {
this.weatherState = {
isRainEnabled: world.isRainEnabled,
rainPercentage: world.rainPercentage,
isFogEnabled: world.isFogEnabled,
fogDensity: world.fogDensity
}
}
} catch (error) {
appLogger.error(`Failed to load weather: ${error instanceof Error ? error.message : String(error)}`)
}
}
public getWeatherState(): WeatherState {
return this.weatherState
}
private startWeatherLoop(): void {
this.intervalId = setInterval(async () => {
this.updateWeather()
this.emitWeather()
await this.saveWeather().catch((error) => {
appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
})
}, WeatherManager.UPDATE_INTERVAL)
}
private updateWeather(): void {
// Update rain
if (Math.random() < WeatherManager.RAIN_CHANCE) {
this.weatherState.isRainEnabled = !this.weatherState.isRainEnabled
this.weatherState.rainPercentage = this.weatherState.isRainEnabled
? Math.floor(Math.random() * 50) + 50 // 50-100%
: 0
}
// Update fog
if (Math.random() < WeatherManager.FOG_CHANCE) {
this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled
this.weatherState.fogDensity = this.weatherState.isFogEnabled
? Math.floor((Math.random() * 0.7 + 0.3) * 100) // Convert 0.3-1.0 to 30-100
: 0
}
}
private emitWeather(): void {
this.io?.emit('weather', this.weatherState)
}
private async saveWeather(): Promise<void> {
try {
await worldService.update({
isRainEnabled: this.weatherState.isRainEnabled,
rainPercentage: this.weatherState.rainPercentage,
isFogEnabled: this.weatherState.isFogEnabled,
fogDensity: this.weatherState.fogDensity
})
} catch (error) {
appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
}
}
public cleanup(): void {
if (this.intervalId) {
clearInterval(this.intervalId)
}
}
}
export default new WeatherManager()

View File

@ -2,71 +2,54 @@ import { Zone } from '@prisma/client'
import ZoneRepository from '../repositories/zoneRepository'
import ZoneService from '../services/zoneService'
import LoadedZone from '../models/loadedZone'
import zoneRepository from '../repositories/zoneRepository'
import { gameMasterLogger } from '../utilities/logger'
import { gameLogger } from '../utilities/logger'
import ZoneCharacter from '../models/zoneCharacter'
class ZoneManager {
private loadedZones: LoadedZone[] = []
private readonly zones = new Map<number, LoadedZone>()
// Method to initialize zoneEditor manager
public async boot() {
public async boot(): Promise<void> {
// Create first zone if it doesn't exist
if (!(await ZoneRepository.getById(1))) {
const zoneService = new ZoneService()
await zoneService.createDemoZone()
await new ZoneService().createDemoZone()
}
const zones = await ZoneRepository.getAll()
await Promise.all(zones.map((zone) => this.loadZone(zone)))
for (const zone of zones) {
await this.loadZone(zone)
}
gameMasterLogger.info('Zone manager loaded')
gameLogger.info(`Zone manager loaded with ${this.zones.size} zones`)
}
public async getZoneAssets(zone: Zone): Promise<ZoneAssets> {
const tiles: string[] = this.getUnique((JSON.parse(JSON.stringify(zone.tiles)) as string[][]).reduce((acc, val) => [...acc, ...val]))
const objects = await zoneRepository.getObjects(zone.id)
const mappedObjects = this.getUnique(objects.map((x) => x.objectId))
return {
tiles: tiles,
objects: mappedObjects
} as ZoneAssets
}
private getUnique<T>(array: T[]) {
return [...new Set<T>(array)]
}
// Method to handle individual zoneEditor loading
public async loadZone(zone: Zone) {
public async loadZone(zone: Zone): Promise<void> {
const loadedZone = new LoadedZone(zone)
this.loadedZones.push(loadedZone)
await this.getZoneAssets(zone)
gameMasterLogger.info(`Zone ID ${zone.id} loaded`)
this.zones.set(zone.id, loadedZone)
gameLogger.info(`Zone ID ${zone.id} loaded`)
}
// Method to handle individual zoneEditor unloading
public unloadZone(zoneId: number) {
this.loadedZones = this.loadedZones.filter((loadedZone) => loadedZone.getZone().id !== zoneId)
gameMasterLogger.info(`Zone ID ${zoneId} unloaded`)
public unloadZone(zoneId: number): void {
this.zones.delete(zoneId)
gameLogger.info(`Zone ID ${zoneId} unloaded`)
}
// Getter for loaded zones
public getLoadedZones(): LoadedZone[] {
return this.loadedZones
return Array.from(this.zones.values())
}
// Getter for zone by id
public getZoneById(zoneId: number): LoadedZone | undefined {
return this.loadedZones.find((loadedZone) => loadedZone.getZone().id === zoneId)
return this.zones.get(zoneId)
}
}
export interface ZoneAssets {
tiles: string[]
objects: string[]
public getCharacter(characterId: number): ZoneCharacter | undefined {
for (const zone of this.zones.values()) {
const character = zone.getCharactersInZone().find((char) => char.character.id === characterId)
if (character) return character
}
return undefined
}
public removeCharacter(characterId: number): void {
this.zones.forEach((zone) => zone.removeCharacter(characterId))
}
}
export default new ZoneManager()

View File

@ -37,7 +37,7 @@ export async function Authentication(socket: TSocket, next: any) {
return next(new Error('Authentication error'))
}
socket.user = (await UserRepository.getById(decoded.id)) as User
socket.userId = decoded.id
next()
})
} else {

View File

@ -1,11 +1,10 @@
import { Zone } from '@prisma/client'
import zoneRepository from '../repositories/zoneRepository'
import characterManager from '../managers/characterManager'
import { ExtendedCharacter } from '../utilities/types'
import { Character, Zone } from '@prisma/client'
import zoneEventTileRepository from '../repositories/zoneEventTileRepository'
import ZoneCharacter from './zoneCharacter'
class LoadedZone {
private readonly zone: Zone
// private readonly npcs: ZoneNPC[] = []
private characters: ZoneCharacter[] = []
constructor(zone: Zone) {
this.zone = zone
@ -15,10 +14,31 @@ class LoadedZone {
return this.zone
}
public addCharacter(character: Character) {
const zoneCharacter = new ZoneCharacter(character)
this.characters.push(zoneCharacter)
}
public async removeCharacter(id: number) {
const zoneCharacter = this.getCharacterById(id)
if (zoneCharacter) {
await zoneCharacter.savePosition()
this.characters = this.characters.filter((c) => c.character.id !== id)
}
}
public getCharacterById(id: number): ZoneCharacter | undefined {
return this.characters.find((c) => c.character.id === id)
}
public getCharactersInZone(): ZoneCharacter[] {
return this.characters
}
public async getGrid(): Promise<number[][]> {
let grid: number[][] = Array.from({ length: this.zone.height }, () => Array.from({ length: this.zone.width }, () => 0))
const eventTiles = await zoneRepository.getEventTiles(this.zone.id)
const eventTiles = await zoneEventTileRepository.getAll(this.zone.id)
// Set the grid values based on the event tiles, these are strings
eventTiles.forEach((eventTile) => {
@ -29,20 +49,6 @@ class LoadedZone {
return grid
}
/**
* @TODO: Implement this
* @param position
*/
public async isPositionWalkable(position: { x: number; y: number }): Promise<boolean> {
const grid = await this.getGrid()
if (!grid?.length) return false
const gridX = Math.floor(position.x)
const gridY = Math.floor(position.y)
return grid[gridY]?.[gridX] === 1 || grid[gridY]?.[Math.ceil(position.x)] === 1 || grid[Math.ceil(position.y)]?.[gridX] === 1 || grid[Math.ceil(position.y)]?.[Math.ceil(position.x)] === 1
}
}
export default LoadedZone

View File

@ -0,0 +1,19 @@
import { Character } from '@prisma/client'
import { CharacterService } from '../services/character/characterService'
class ZoneCharacter {
public readonly character: Character
public isMoving: boolean = false
public currentPath: Array<{ x: number; y: number }> | null = null
constructor(character: Character) {
this.character = character
}
public async savePosition() {
const characterService = new CharacterService()
await characterService.updateCharacterPosition(this.character.id, this.character.positionX, this.character.positionY, this.character.rotation, this.character.zoneId)
}
}
export default ZoneCharacter

View File

@ -1,5 +1,6 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { Character } from '@prisma/client'
import { appLogger } from '../utilities/logger'
class CharacterRepository {
async getByUserId(userId: number): Promise<Character[] | null> {
@ -19,7 +20,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get character by user ID: ${error.message}`)
appLogger.error(`Failed to get character by user ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -41,7 +43,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get character by user ID and character ID: ${error.message}`)
appLogger.error(`Failed to get character by user ID and character ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -62,7 +65,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get character by ID: ${error.message}`)
appLogger.error(`Failed to get character by ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -79,7 +83,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to update character: ${error.message}`)
appLogger.error(`Failed to update character: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -93,7 +98,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to delete character by user ID and character ID: ${error.message}`)
appLogger.error(`Failed to delete character by user ID and character ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -114,7 +120,8 @@ class CharacterRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get character by name: ${error.message}`)
appLogger.error(`Failed to get character by name: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
}

View File

@ -0,0 +1,10 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { CharacterType } from '@prisma/client'
class CharacterTypeRepository {
async getAll(): Promise<CharacterType[]> {
return prisma.characterType.findMany()
}
}
export default new CharacterTypeRepository()

View File

@ -0,0 +1,49 @@
import prisma from '../utilities/prisma'
import { Chat } from '@prisma/client'
class ChatRepository {
async getById(id: number): Promise<Chat | null> {
return prisma.chat.findUnique({
where: { id },
include: {
character: true,
zone: true
}
})
}
async getAll(): Promise<Chat[]> {
return prisma.chat.findMany({
include: {
character: true,
zone: true
}
})
}
async getByCharacterId(characterId: number): Promise<Chat[]> {
return prisma.chat.findMany({
where: {
characterId
},
include: {
character: true,
zone: true
}
})
}
async getByZoneId(zoneId: number): Promise<Chat[]> {
return prisma.chat.findMany({
where: {
zoneId
},
include: {
character: true,
zone: true
}
})
}
}
export default new ChatRepository()

View File

@ -0,0 +1,45 @@
import prisma from '../utilities/prisma'
import { appLogger } from '../utilities/logger' // Import the global Prisma instance
class PasswordResetTokenRepository {
async getById(id: number): Promise<any> {
try {
return await prisma.passwordResetToken.findUnique({
where: {
id
}
})
} catch (error: any) {
// Handle error
appLogger.error(`Failed to get password reset token by ID: ${error instanceof Error ? error.message : String(error)}`)
}
}
async getByUserId(userId: number): Promise<any> {
try {
return await prisma.passwordResetToken.findFirst({
where: {
userId
}
})
} catch (error: any) {
// Handle error
appLogger.error(`Failed to get password reset token by user ID: ${error instanceof Error ? error.message : String(error)}`)
}
}
async getByToken(token: string): Promise<any> {
try {
return await prisma.passwordResetToken.findFirst({
where: {
token
}
})
} catch (error: any) {
// Handle error
appLogger.error(`Failed to get password reset token by token: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
export default new PasswordResetTokenRepository()

View File

@ -1,5 +1,5 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { Sprite, SpriteAction } from '@prisma/client'
import { SpriteAction } from '@prisma/client'
class SpriteRepository {
async getById(id: string) {

View File

@ -1,5 +1,8 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { Tile } from '@prisma/client'
import zoneRepository from './zoneRepository'
import { unduplicateArray } from '../utilities/utilities'
import { FlattenZoneArray } from '../utilities/zone'
class TileRepository {
async getById(id: string): Promise<Tile | null> {
@ -8,9 +11,28 @@ class TileRepository {
})
}
async getByIds(ids: string[]): Promise<Tile[]> {
return prisma.tile.findMany({
where: {
id: {
in: ids
}
}
})
}
async getAll(): Promise<Tile[]> {
return prisma.tile.findMany()
}
async getByZoneId(zoneId: number): Promise<Tile[]> {
const zone = await zoneRepository.getById(zoneId)
if (!zone) return []
const zoneTileArray = unduplicateArray(FlattenZoneArray(JSON.parse(JSON.stringify(zone.tiles))))
return this.getByIds(zoneTileArray)
}
}
export default new TileRepository()

View File

@ -1,5 +1,6 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { User } from '@prisma/client'
import { appLogger } from '../utilities/logger'
class UserRepository {
async getById(id: number): Promise<User | null> {
@ -11,7 +12,8 @@ class UserRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get user by ID: ${error.message}`)
appLogger.error(`Failed to get user by ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
@ -24,7 +26,22 @@ class UserRepository {
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to get user by username: ${error.message}`)
appLogger.error(`Failed to get user by username: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
async getByEmail(email: string): Promise<User | null> {
try {
return await prisma.user.findUnique({
where: {
email
}
})
} catch (error: any) {
// Handle error
appLogger.error(`Failed to get user by email: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
}

View File

@ -0,0 +1,19 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { World } from '@prisma/client'
import { gameLogger } from '../utilities/logger'
class WorldRepository {
async getFirst(): Promise<World | null> {
try {
return await prisma.world.findFirst({
orderBy: { date: 'desc' }
})
} catch (error: any) {
// Handle error
gameLogger.error(`Failed to get first world: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
}
export default new WorldRepository()

View File

@ -0,0 +1,37 @@
import { ZoneEventTile } from '@prisma/client'
import prisma from '../utilities/prisma'
import { appLogger } from '../utilities/logger'
class ZoneEventTileRepository {
async getAll(id: number): Promise<ZoneEventTile[]> {
try {
return await prisma.zoneEventTile.findMany({
where: {
zoneId: id
}
})
} catch (error: any) {
appLogger.error(`Failed to get zone event tiles: ${error.message}`)
return []
}
}
async getEventTileByZoneIdAndPosition(zoneId: number, positionX: number, positionY: number) {
try {
return await prisma.zoneEventTile.findFirst({
where: {
zoneId: zoneId,
positionX: positionX,
positionY: positionY
},
include: { teleport: true }
})
} catch (error: any) {
appLogger.error(`Failed to get zone event tile: ${error.message}`)
return null
}
}
}
export default new ZoneEventTileRepository()

View File

@ -1,18 +1,9 @@
import { Zone, ZoneEventTile, ZoneEventTileType, ZoneObject } from '@prisma/client'
import prisma from '../utilities/prisma'
import { ZoneEventTileWithTeleport } from '../socketEvents/zone/characterMove'
import { ZoneEventTileWithTeleport } from '../utilities/types'
import { appLogger } from '../utilities/logger'
class ZoneRepository {
async getFirst(): Promise<Zone | null> {
try {
return await prisma.zone.findFirst()
} catch (error: any) {
appLogger.error(`Failed to get first zone: ${error.message}`)
return null
}
}
async getAll(): Promise<Zone[]> {
try {
return await prisma.zone.findMany()
@ -22,7 +13,7 @@ class ZoneRepository {
}
}
async getById(id: number): Promise<Zone | null> {
async getById(id: number) {
try {
return await prisma.zone.findUnique({
where: {
@ -39,7 +30,8 @@ class ZoneRepository {
include: {
object: true
}
}
},
zoneEffects: true
}
})
} catch (error: any) {
@ -61,22 +53,23 @@ class ZoneRepository {
}
}
async getEventTeleportTiles(id: number): Promise<ZoneEventTileWithTeleport[]> {
async getFirstEventTile(zoneId: number, positionX: number, positionY: number): Promise<ZoneEventTile | null> {
try {
return (await prisma.zoneEventTile.findMany({
return await prisma.zoneEventTile.findFirst({
where: {
zoneId: id,
type: ZoneEventTileType.TELEPORT
zoneId: zoneId,
positionX: positionX,
positionY: positionY
},
include: { teleport: true }
})) as unknown as ZoneEventTileWithTeleport[]
})
} catch (error: any) {
appLogger.error(`Failed to get zone event tiles: ${error.message}`)
return []
appLogger.error(`Failed to get zone event tile: ${error.message}`)
return null
}
}
async getObjects(id: number): Promise<ZoneObject[]> {
async getZoneObjects(id: number): Promise<ZoneObject[]> {
try {
return await prisma.zoneObject.findMany({
where: {

View File

@ -1,21 +1,21 @@
import fs from 'fs'
import path from 'path'
import express, { Application } from 'express'
import config from './utilities/config'
import { getAppPath } from './utilities/storage'
import { createServer as httpServer, Server as HTTPServer } from 'http'
import { addHttpRoutes } from './utilities/http'
import cors from 'cors'
import { Server as SocketServer } from 'socket.io'
import { Authentication } from './middleware/authentication'
import { TSocket } from './utilities/types'
import config from './utilities/config'
import prisma from './utilities/prisma'
import { appLogger, watchLogs } from './utilities/logger'
import ZoneManager from './managers/zoneManager'
import UserManager from './managers/userManager'
import { Authentication } from './middleware/authentication'
// import CommandManager from './managers/CommandManager'
import { Dirent } from 'node:fs'
import { appLogger, watchLogs } from './utilities/logger'
import CharacterManager from './managers/characterManager'
import CommandManager from './managers/commandManager'
import QueueManager from './managers/queueManager'
import DateManager from './managers/dateManager'
import WeatherManager from './managers/weatherManager'
export class Server {
private readonly app: Application
@ -27,7 +27,14 @@ export class Server {
*/
constructor() {
this.app = express()
this.app.use(cors())
this.app.use(
cors({
origin: config.CLIENT_URL,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Add supported methods
allowedHeaders: ['Content-Type', 'Authorization'], // Add allowed headers
credentials: true
})
)
this.app.use(express.json())
this.app.use(express.urlencoded({ extended: true }))
this.http = httpServer(this.app)
@ -58,23 +65,26 @@ export class Server {
appLogger.error(`Socket.IO failed to start: ${error.message}`)
}
// Load queue manager
await QueueManager.boot(this.io)
// Add http API routes
await addHttpRoutes(this.app)
// Load queue manager
await QueueManager.boot(this.io)
// Load user manager
await UserManager.boot()
// Load date manager
await DateManager.boot(this.io)
// Load weather manager
await WeatherManager.boot(this.io)
// Load zoneEditor manager
await ZoneManager.boot()
// Load character manager
await CharacterManager.boot()
// Load command manager - Disabled for now
// await CommandManager.boot(this.io);
// Load command manager
await CommandManager.boot(this.io)
// Listen for socket connections
this.io.on('connection', this.handleConnection.bind(this))
@ -86,42 +96,46 @@ export class Server {
* @private
*/
private async handleConnection(socket: TSocket) {
const eventsPath = path.join(__dirname, 'socketEvents')
try {
await this.loadEventHandlers(eventsPath, socket)
await this.loadEventHandlers('socketEvents', '', socket)
} catch (error: any) {
appLogger.error(`Failed to load event handlers: ${error.message}`)
}
}
private async loadEventHandlers(dir: string, socket: TSocket) {
const files: Dirent[] = await fs.promises.readdir(dir, { withFileTypes: true })
private async loadEventHandlers(baseDir: string, subDir: string, socket: TSocket) {
try {
const fullDir = getAppPath(baseDir, subDir)
const files = await fs.promises.readdir(fullDir, { withFileTypes: true })
for (const file of files) {
const fullPath = path.join(dir, file.name)
for (const file of files) {
const filePath = getAppPath(baseDir, subDir, file.name)
if (file.isDirectory()) {
await this.loadEventHandlers(baseDir, `${subDir}/${file.name}`, socket)
continue
}
if (!file.isFile() || (!file.name.endsWith('.ts') && !file.name.endsWith('.js'))) {
continue
}
if (file.isDirectory()) {
await this.loadEventHandlers(fullPath, socket)
} else if (file.isFile() && (file.name.endsWith('.ts') || file.name.endsWith('.js'))) {
try {
const module = await import(fullPath)
if (typeof module.default === 'function') {
if (module.default.prototype && module.default.prototype.listen) {
// This is a class-based event
const EventClass = module.default
const eventInstance = new EventClass(this.io, socket)
eventInstance.listen()
} else {
// This is a function-based event
module.default(this.io, socket)
}
} else {
const module = await import(filePath)
if (typeof module.default !== 'function') {
appLogger.warn(`Unrecognized export in ${file.name}`)
continue
}
} catch (error: any) {
appLogger.error(`Error loading event handler ${file.name}: ${error.message}`)
const EventClass = module.default
const eventInstance = new EventClass(this.io, socket)
eventInstance.listen()
} catch (error) {
appLogger.error(`Error loading event handler ${file.name}: ${error instanceof Error ? error.message : String(error)}`)
}
}
} catch (error) {
appLogger.error(`Error reading directory: ${error instanceof Error ? error.message : String(error)}`)
}
}
}

View File

@ -1,3 +0,0 @@
class AssetService {}
export default AssetService

View File

@ -1,43 +0,0 @@
import { ExtendedCharacter } from '../../utilities/types'
import { AStar } from '../../utilities/character/aStar'
import ZoneManager from '../../managers/zoneManager'
import Rotation from '../../utilities/character/rotation'
import { gameLogger } from '../../utilities/logger'
export class CharacterMoveService {
public updatePosition(character: ExtendedCharacter, position: { x: number; y: number }, newZoneId?: number) {
Object.assign(character, {
positionX: position.x,
positionY: position.y,
rotation: Rotation.calculate(character.positionX, character.positionY, position.x, position.y),
zoneId: newZoneId || character.zoneId
})
// await prisma.character.update({
// where: { id: character.id },
// data: {
// positionX: position.x,
// positionY: position.y,
// rotation: character.rotation,
// zoneId: newZoneId
// }
// })
}
public async calculatePath(character: ExtendedCharacter, targetX: number, targetY: number): Promise<Array<{ x: number; y: number }> | null> {
const grid = await ZoneManager.getZoneById(character.zoneId)?.getGrid()
if (!grid?.length) {
gameLogger.error('character:move error', 'Grid not found or empty')
return null
}
const start = { x: Math.floor(character.positionX), y: Math.floor(character.positionY) }
const end = { x: Math.floor(targetX), y: Math.floor(targetY) }
return AStar.findPath(start, end, grid)
}
public async applyMovementDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 250)) // 250ms delay between steps
}
}

View File

@ -1,5 +1,80 @@
import { Character } from '@prisma/client'
import { AStar } from '../../utilities/character/aStar'
import ZoneManager from '../../managers/zoneManager'
import prisma from '../../utilities/prisma'
import Rotation from '../../utilities/character/rotation'
import { gameLogger } from '../../utilities/logger'
import { Character, CharacterGender, CharacterRace } from '@prisma/client'
class CharacterService {}
interface Position {
x: number
y: number
}
export default CharacterService
export class CharacterService {
private static readonly MOVEMENT_DELAY_MS = 250
public updatePosition(character: Character, position: Position, newZoneId?: number): void {
if (!this.isValidPosition(position)) {
gameLogger.error(`Invalid position coordinates: ${position.x}, ${position.y}`)
}
Object.assign(character, {
positionX: position.x,
positionY: position.y,
rotation: Rotation.calculate(character.positionX, character.positionY, position.x, position.y),
zoneId: newZoneId ?? character.zoneId
})
}
public async calculatePath(character: Character, targetX: number, targetY: number): Promise<Position[] | null> {
const zone = ZoneManager.getZoneById(character.zoneId)
const grid = await zone?.getGrid()
if (!grid?.length) {
gameLogger.error('character:move error', 'Grid not found or empty')
return null
}
const start: Position = {
x: Math.floor(character.positionX),
y: Math.floor(character.positionY)
}
const end: Position = {
x: Math.floor(targetX),
y: Math.floor(targetY)
}
return AStar.findPath(start, end, grid)
}
public async applyMovementDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, CharacterService.MOVEMENT_DELAY_MS))
}
private isValidPosition(position: Position): boolean {
return Number.isFinite(position.x) && Number.isFinite(position.y) && position.x >= 0 && position.y >= 0
}
async create(name: string, userId: number) {
return prisma.character.create({
data: {
name,
userId
// characterTypeId: 1 // @TODO set to chosen character type
}
})
}
async updateCharacterPosition(id: number, positionX: number, positionY: number, rotation: number, zoneId: number) {
await prisma.character.update({
where: { id },
data: {
positionX,
positionY,
rotation,
zoneId
}
})
}
}

View File

@ -0,0 +1,30 @@
import prisma from '../utilities/prisma'
import { gameLogger } from '../utilities/logger'
import { Server } from 'socket.io'
import { TSocket } from '../utilities/types'
import ChatRepository from '../repositories/chatRepository'
class ChatService {
async sendZoneMessage(io: Server, socket: TSocket, message: string, characterId: number, zoneId: number): Promise<boolean> {
try {
const newChat = await prisma.chat.create({
data: {
characterId,
zoneId,
message
}
})
const chat = await ChatRepository.getById(newChat.id)
if (!chat) return false
io.to(zoneId.toString()).emit('chat:message', chat)
return true
} catch (error: any) {
gameLogger.error(`Failed to save chat message: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
}
export default ChatService

View File

@ -0,0 +1,31 @@
import prisma from '../utilities/prisma'
import passwordResetTokenRepository from '../repositories/passwordResetTokenRepository'
import { appLogger } from '../utilities/logger'
class PasswordResetTokenService {
/**
* Delete token
* @param token
*/
public async delete(token: string): Promise<boolean> {
try {
const tokenData = await passwordResetTokenRepository.getByToken(token)
if (!tokenData) {
return false
}
await prisma.passwordResetToken.delete({
where: {
token
}
})
return true
} catch (error: any) {
appLogger.error(`Error deleting password reset token: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
}
export default PasswordResetTokenService

View File

@ -1,7 +1,12 @@
import bcrypt from 'bcryptjs'
import UserRepository from '../repositories/userRepository'
import PasswordResetTokenRepository from '../repositories/passwordResetTokenRepository'
import prisma from '../utilities/prisma'
import { User } from '@prisma/client'
import config from '../utilities/config'
import NodeMailer from 'nodemailer'
import { httpLogger } from '../utilities/logger'
import PasswordResetTokenService from './passwordResetTokenService'
/**
* User service
@ -15,37 +20,145 @@ class UserService {
* @param password
*/
async login(username: string, password: string): Promise<boolean | User> {
const user = await UserRepository.getByUsername(username)
if (!user) {
try {
const user = await UserRepository.getByUsername(username)
if (!user) {
return false
}
const passwordMatch = await bcrypt.compare(password, user.password)
if (!passwordMatch) {
httpLogger.error(`Failed to login user: ${username}`)
return false
}
return user
} catch (error: any) {
httpLogger.error(`Error logging in user: ${error instanceof Error ? error.message : String(error)}`)
return false
}
const passwordMatch = await bcrypt.compare(password, user.password)
if (!passwordMatch) {
return false
}
return user
}
/**
* Register user
* @param username
* @param email
* @param password
*/
async register(username: string, password: string): Promise<boolean | User> {
const user = await UserRepository.getByUsername(username)
if (user) {
async register(username: string, email: string, password: string): Promise<boolean | User> {
try {
const user = await UserRepository.getByUsername(username)
if (user) {
return false
}
const userByEmail = await UserRepository.getByEmail(email)
if (userByEmail) {
httpLogger.error(`User already exists: ${email}`)
return false
}
const hashedPassword = await bcrypt.hash(password, 10)
return prisma.user.create({
data: {
username,
email,
password: hashedPassword
}
})
} catch (error: any) {
httpLogger.error(`Error registering user: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
const hashedPassword = await bcrypt.hash(password, 10)
return prisma.user.create({
data: {
username,
password: hashedPassword
/**
* Reset password
* @param email
*/
async requestPasswordReset(email: string): Promise<boolean> {
try {
const user = await UserRepository.getByEmail(email)
if (!user) return false
const token = await bcrypt.hash(new Date().getTime().toString(), 10)
const latestToken = await PasswordResetTokenRepository.getByUserId(user.id)
// Check if password reset has been requested recently
if (latestToken) {
const tokenExpiryDate = new Date(Date.now() - 24 * 60 * 60 * 1000) // 24 hours
const isTokenExpired = latestToken.createdAt < tokenExpiryDate
if (!isTokenExpired) return false
await prisma.passwordResetToken.delete({
where: {
id: latestToken.id
}
})
}
})
await prisma.passwordResetToken.create({
data: {
userId: user.id,
token: token
}
})
const transporter = NodeMailer.createTransport({
host: config.SMTP_HOST,
port: config.SMTP_PORT,
secure: false,
auth: {
user: config.SMTP_USER,
pass: config.SMTP_PASSWORD
}
})
await transporter.sendMail({
from: config.SMTP_USER,
to: email,
subject: 'Reset your password',
text: 'A password reset has been requested, reset your password here: ' + config.CLIENT_URL + '#' + token, // Plain text body
html: "<p>A password reset has been requested, reset your password here: <a href='" + config.CLIENT_URL + '#' + token + "'>" + config.CLIENT_URL + '#' + token + '</a></p>' // Html body
})
return true
} catch (error: any) {
httpLogger.error(`Error sending password reset email: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
/**
* Set new password
* @param urlToken
* @param password
*/
async resetPassword(urlToken: string, password: string): Promise<boolean> {
try {
const tokenData = await PasswordResetTokenRepository.getByToken(urlToken)
if (!tokenData) {
return false
}
const hashedPassword = await bcrypt.hash(password, 10)
await prisma.user.update({
where: { id: tokenData.userId },
data: {
password: hashedPassword
}
})
// Delete the token
const passwordResetTokenService = new PasswordResetTokenService()
await passwordResetTokenService.delete(urlToken)
return true
} catch (error: any) {
httpLogger.error(`Error setting new password: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
}

View File

@ -0,0 +1,37 @@
import prisma from '../utilities/prisma'
import { gameLogger } from '../utilities/logger'
import { World } from '@prisma/client'
import WorldRepository from '../repositories/worldRepository'
class WorldService {
async update(worldData: Partial<World>): Promise<boolean> {
try {
const currentWorld = await WorldRepository.getFirst()
if (!currentWorld) {
// If no world exists, create first record
await prisma.world.create({
data: {
...worldData,
date: worldData.date || new Date()
}
})
return true
}
// Update existing world using its date as unique identifier
await prisma.world.update({
where: {
date: currentWorld.date
},
data: worldData
})
return true
} catch (error: any) {
gameLogger.error(`Failed to update world: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
}
export default new WorldService()

View File

@ -1,18 +1,21 @@
import { ExtendedCharacter, TSocket } from '../utilities/types'
import prisma from '../utilities/prisma'
import ZoneRepository from '../repositories/zoneRepository'
import { ZoneEventTileTeleport } from '@prisma/client'
import { Server } from 'socket.io'
import CharacterManager from '../managers/characterManager'
import ZoneManager from '../managers/zoneManager'
import { gameLogger } from '../utilities/logger'
export class ZoneEventTileService {
public async handleTeleport(io: Server, socket: TSocket, character: ExtendedCharacter, teleport: ZoneEventTileTeleport): Promise<void> {
if (teleport.toZoneId === character.zoneId) return
const zone = await ZoneRepository.getById(teleport.toZoneId)
if (!zone) return
const loadedZone = ZoneManager.getZoneById(teleport.toZoneId)
if (!loadedZone) {
gameLogger.error('zone:character:join error', 'Loaded zone not found')
return
}
// CharacterManager.moveCharacterBetweenZones(character, zone)
const zone = loadedZone.getZone()
const oldZoneId = character.zoneId
const newZoneId = teleport.toZoneId
@ -23,14 +26,21 @@ export class ZoneEventTileService {
data: {
zoneId: newZoneId,
positionX: teleport.toPositionX,
positionY: teleport.toPositionY
positionY: teleport.toPositionY,
rotation: teleport.toRotation
}
})
// Update local character object
character.zoneId = newZoneId
character.rotation = teleport.toRotation
character.positionX = teleport.toPositionX
character.positionY = teleport.toPositionY
character.isMoving = false
// Remove and add character to new zone
await loadedZone.removeCharacter(character.id)
loadedZone.addCharacter(character)
// Emit events
io.to(oldZoneId.toString()).emit('zone:character:leave', character.id)
@ -43,7 +53,7 @@ export class ZoneEventTileService {
// Send teleport information to the client
socket.emit('zone:character:teleport', {
zone,
characters: CharacterManager.getCharactersInZone(zone)
characters: loadedZone.getCharactersInZone()
})
}
}

View File

@ -1,31 +1,38 @@
import prisma from '../utilities/prisma'
import { gameLogger } from '../utilities/logger'
class ZoneService {
async createDemoZone(): Promise<boolean> {
const tiles = [
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile']
]
try {
const tiles = [
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile'],
['blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile', 'blank_tile']
]
await prisma.zone.create({
data: {
name: 'Demo zone',
width: 10,
height: 10,
tiles
}
})
await prisma.zone.create({
data: {
name: 'Demo zone',
width: 10,
height: 10,
tiles
}
})
console.log('Demo zone created.')
return true
gameLogger.info('Demo zone created.')
return true
} catch (error: any) {
gameLogger.error(`Failed to create demo zone: ${error instanceof Error ? error.message : String(error)}`)
return false
}
}
}

View File

@ -1,24 +1,65 @@
import { Server } from 'socket.io'
import { TSocket, ExtendedCharacter } from '../../utilities/types'
import { TSocket } from '../../utilities/types'
import CharacterRepository from '../../repositories/characterRepository'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger'
import ZoneManager from '../../managers/zoneManager'
type SocketResponseT = {
character_id: number
interface CharacterConnectPayload {
characterId: number
}
export default function (io: Server, socket: TSocket) {
socket.on('character:connect', async (data: SocketResponseT) => {
console.log('character:connect requested', data)
try {
const character = await CharacterRepository.getByUserAndId(socket?.user?.id as number, data.character_id)
if (!character) return
socket.characterId = character.id
export default class CharacterConnectEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
CharacterManager.initCharacter(character as ExtendedCharacter)
socket.emit('character:connect', character)
} catch (error: any) {
console.log('character:connect error', error)
public listen(): void {
this.socket.on('character:connect', this.handleCharacterConnect.bind(this))
}
private async handleCharacterConnect({ characterId }: CharacterConnectPayload): Promise<void> {
if (!this.socket.userId) {
this.emitError('User not authenticated')
return
}
})
try {
if (await this.hasActiveCharacter()) {
this.emitError('You are already connected to another character')
return
}
const character = await this.connectCharacter(characterId)
if (!character) {
this.emitError('Character not found or does not belong to this user')
return
}
this.socket.characterId = character.id
this.socket.emit('character:connect', character)
} catch (error) {
this.handleError('Failed to connect character', error) // @TODO : Make global error handler
}
}
private async hasActiveCharacter(): Promise<boolean> {
const characters = await CharacterRepository.getByUserId(this.socket.userId!)
return characters?.some((char) => ZoneManager.getCharacter(char.id)) ?? false
}
private async connectCharacter(characterId: number) {
return CharacterRepository.getByUserAndId(this.socket.userId!, characterId)
}
private emitError(message: string): void {
this.socket.emit('notification', { title: 'Server message', message })
gameLogger.error('character:connect error', `Player ${this.socket.userId}: ${message}`)
}
private handleError(context: string, error: unknown): void {
const errorMessage = error instanceof Error ? error.message : String(error)
this.emitError(`${context}: ${errorMessage}`)
gameLogger.error('character:connect error', errorMessage)
}
}

View File

@ -2,50 +2,57 @@ import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import { Character } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository'
import { CharacterService } from '../../services/character/characterService'
import { ZCharacterCreate } from '../../utilities/zodTypes'
import prisma from '../../utilities/prisma'
import { gameLogger } from '../../utilities/logger'
import { ZodError } from 'zod'
export default function (io: Server, socket: TSocket) {
socket.on('character:create', async (data: any) => {
export default class CharacterCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:create', this.handleCharacterCreate.bind(this))
}
private async handleCharacterCreate(data: any): Promise<any> {
console.log('character:create requested', data)
// zod validate
try {
data = ZCharacterCreate.parse(data)
const user_id = socket.user?.id as number
const user_id = this.socket.userId!
// Check if character name already exists
const characterExists = await CharacterRepository.getByName(data.name)
if (characterExists) {
return socket.emit('notification', { message: 'Character name already exists' })
return this.socket.emit('notification', { message: 'Character name already exists' })
}
let characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
if (characters.length >= 4) {
return socket.emit('notification', { message: 'You can only have 4 characters' })
return this.socket.emit('notification', { message: 'You can only have 4 characters' })
}
const character: Character = await prisma.character.create({
data: {
name: data.name,
userId: user_id
// characterTypeId: 1 // @TODO set to chosen character type
}
})
const characterService = new CharacterService()
const character: Character = await characterService.create(data.name, user_id)
characters = [...characters, character]
socket.emit('character:create:success')
socket.emit('character:list', characters)
this.socket.emit('character:create:success')
this.socket.emit('character:list', characters)
gameLogger.info('character:create success')
} catch (error: any) {
console.log(error)
gameLogger.error(`character:create error: ${error.message}`)
return socket.emit('notification', { message: 'Could not create character. Please try again (later).' })
if (error instanceof ZodError) {
return this.socket.emit('notification', { message: error.issues[0].message })
}
return this.socket.emit('notification', { message: 'Could not create character. Please try again (later).' })
}
})
}
}

View File

@ -4,7 +4,7 @@ import { Character, Zone } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository'
type TypePayload = {
character_id: number
characterId: number
}
type TypeResponse = {
@ -12,19 +12,25 @@ type TypeResponse = {
characters: Character[]
}
export default function (io: Server, socket: TSocket) {
socket.on('character:delete', async (data: TypePayload, callback: (response: TypeResponse) => void) => {
// zod validate
export default class CharacterDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:delete', this.handleCharacterDelete.bind(this))
}
private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> {
try {
await CharacterRepository.deleteByUserIdAndId(socket.user?.id as number, data.character_id as number)
await CharacterRepository.deleteByUserIdAndId(this.socket.userId!, data.characterId!)
const user_id = socket.user?.id as number
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[]
socket.emit('character:list', characters)
this.socket.emit('character:list', characters)
} catch (error: any) {
console.log(error)
return socket.emit('notification', { message: 'Character delete failed. Please try again.' })
return this.socket.emit('notification', { message: 'Character delete failed. Please try again.' })
}
})
}
}

View File

@ -2,16 +2,24 @@ import { Socket, Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import { Character } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository'
import { gameLogger } from '../../utilities/logger'
export default function CharacterList(io: Server, socket: TSocket) {
socket.on('character:list', async (data: any) => {
export default class CharacterListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:list', this.handleCharacterList.bind(this))
}
private async handleCharacterList(data: any): Promise<void> {
try {
console.log('character:list requested')
const user_id = socket.user?.id as number
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
socket.emit('character:list', characters)
const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[]
this.socket.emit('character:list', characters)
} catch (error: any) {
console.log('character:list error', error)
gameLogger.error('character:list error', error.message)
}
})
}
}

View File

@ -15,7 +15,7 @@ export default class AlertCommandEvent {
) {}
public listen(): void {
this.socket.on('chat:send_message', this.handleAlertCommand.bind(this))
this.socket.on('chat:message', this.handleAlertCommand.bind(this))
}
private async handleAlertCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
@ -24,25 +24,30 @@ export default class AlertCommandEvent {
return
}
// Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found')
return callback(false)
}
// Check if the user is the GM
if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
return callback(false)
}
const args = getArgs('alert', data.message)
if (!args) {
callback(false)
return
}
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number)
if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found')
callback(false)
return
return callback(false)
}
this.io.emit('notification', { title: 'Message from GM', message: args.join(' ') })
callback(true)
return callback(true)
} catch (error: any) {
gameLogger.error('chat:alert_command error', error.message)
callback(false)
}
}
}
}

View File

@ -0,0 +1,60 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types'
import { getArgs, isCommand } from '../../../utilities/chat'
import CharacterRepository from '../../../repositories/characterRepository'
import { gameLogger } from '../../../utilities/logger'
import DateManager from '../../../managers/dateManager'
type TypePayload = {
message: string
}
export default class SetTimeCommand {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('chat:message', this.handleAlertCommand.bind(this))
}
private async handleAlertCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
if (!isCommand(data.message, 'time')) {
return
}
// Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found')
return
}
// Check if the user is the GM
if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
return
}
// Get arguments
const args = getArgs('time', data.message)
if (!args) {
return
}
const time = args[0] // 24h time, e.g. 17:34
if (!time) {
return
}
await DateManager.setTime(time)
} catch (error: any) {
gameLogger.error('command error', error.message)
callback(false)
}
}
}

View File

@ -2,8 +2,9 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types'
import { getArgs, isCommand } from '../../../utilities/chat'
import ZoneRepository from '../../../repositories/zoneRepository'
import CharacterManager from '../../../managers/characterManager'
import { gameMasterLogger } from '../../../utilities/logger'
import { gameLogger, gameMasterLogger } from '../../../utilities/logger'
import ZoneManager from '../../../managers/zoneManager'
import ZoneCharacter from '../../../models/zoneCharacter'
type TypePayload = {
message: string
@ -16,14 +17,23 @@ export default class TeleportCommandEvent {
) {}
public listen(): void {
this.socket.on('chat:send_message', this.handleTeleportCommand.bind(this))
this.socket.on('chat:message', this.handleTeleportCommand.bind(this))
}
private async handleTeleportCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) {
this.socket.emit('notification', { title: 'Server message', message: 'Character not found' })
// Check if character exists
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
gameLogger.error('chat:message error', 'Character not found')
return
}
const character = zoneCharacter.character
// Check if the user is the GM
if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
return
}
@ -65,17 +75,15 @@ export default class TeleportCommandEvent {
character.positionX = 0
character.positionY = 0
character.resetMovement = true
zoneCharacter.isMoving = false
this.socket.emit('zone:character:teleport', {
zone,
characters: CharacterManager.getCharactersInZone(zone)
characters: ZoneManager.getZoneById(zone.id)?.getCharactersInZone()
})
this.socket.emit('notification', { title: 'Server message', message: `You have been teleported to ${zone.name}` })
gameMasterLogger.info('teleport', `Character ${character.id} teleported to zone ${zone.id}`)
callback(true)
} catch (error: any) {
gameMasterLogger.error(`Error in teleport command: ${error.message}`)
this.socket.emit('notification', { title: 'Server message', message: 'An error occurred while teleporting' })

View File

@ -0,0 +1,47 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types'
import { isCommand } from '../../../utilities/chat'
import CharacterRepository from '../../../repositories/characterRepository'
import { gameLogger } from '../../../utilities/logger'
import WeatherManager from '../../../managers/weatherManager'
type TypePayload = {
message: string
}
export default class ToggleFogCommand {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('chat:message', this.handleAlertCommand.bind(this))
}
private async handleAlertCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
if (!isCommand(data.message, 'fog')) {
return
}
// Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found')
return
}
// Check if the user is the GM
if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
return
}
await WeatherManager.toggleFog()
} catch (error: any) {
gameLogger.error('command error', error.message)
callback(false)
}
}
}

View File

@ -0,0 +1,47 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types'
import { isCommand } from '../../../utilities/chat'
import CharacterRepository from '../../../repositories/characterRepository'
import { gameLogger } from '../../../utilities/logger'
import WeatherManager from '../../../managers/weatherManager'
type TypePayload = {
message: string
}
export default class ToggleRainCommand {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('chat:message', this.handleAlertCommand.bind(this))
}
private async handleAlertCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
if (!isCommand(data.message, 'rain')) {
return
}
// Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found')
return
}
// Check if the user is the GM
if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
return
}
await WeatherManager.toggleRain()
} catch (error: any) {
gameLogger.error('command error', error.message)
callback(false)
}
}
}

View File

@ -0,0 +1,54 @@
import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import ZoneRepository from '../../repositories/zoneRepository'
import { isCommand } from '../../utilities/chat'
import { gameLogger } from '../../utilities/logger'
import ZoneManager from '../../managers/zoneManager'
import ChatService from '../../services/chatService'
type TypePayload = {
message: string
}
export default class ChatMessageEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('chat:message', this.handleEvent.bind(this))
}
private async handleEvent(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
if (!data.message || isCommand(data.message)) {
return callback(false)
}
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
gameLogger.error('chat:message error', 'Character not found')
return callback(false)
}
const character = zoneCharacter.character
const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) {
gameLogger.error('chat:message error', 'Zone not found')
return callback(false)
}
const chatService = new ChatService()
if (await chatService.sendZoneMessage(this.io, this.socket, data.message, character.id, zone.id)) {
return callback(true)
}
callback(false)
} catch (error: any) {
gameLogger.error('chat:message error', error.message)
callback(false)
}
}
}

View File

@ -1,54 +0,0 @@
import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import CharacterRepository from '../../repositories/characterRepository'
import ZoneRepository from '../../repositories/zoneRepository'
import { isCommand } from '../../utilities/chat'
import { gameLogger } from '../../utilities/logger'
type TypePayload = {
message: string
}
export default class ChatMessageEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('chat:send_message', this.handleChatMessage.bind(this))
}
private async handleChatMessage(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try {
if (!data.message || isCommand(data.message)) {
callback(false)
return
}
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number)
if (!character) {
gameLogger.error('chat:send_message error', 'Character not found')
callback(false)
return
}
const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) {
gameLogger.error('chat:send_message error', 'Zone not found')
callback(false)
return
}
callback(true)
this.io.to(zone.id.toString()).emit('chat:message', {
character: character,
message: data.message
})
} catch (error: any) {
gameLogger.error('chat:send_message error', error.message)
callback(false)
}
}
}

View File

@ -1,7 +1,7 @@
import { Server } from 'socket.io'
import { TSocket } from '../utilities/types'
import CharacterManager from '../managers/characterManager'
import { gameLogger } from '../utilities/logger'
import ZoneManager from '../managers/zoneManager'
export default class DisconnectEvent {
constructor(
@ -10,33 +10,38 @@ export default class DisconnectEvent {
) {}
public listen(): void {
this.socket.on('disconnect', this.handleDisconnect.bind(this))
this.socket.on('disconnect', this.handleEvent.bind(this))
}
private async handleDisconnect(data: any): Promise<void> {
private async handleEvent(data: any): Promise<void> {
try {
if (!this.socket.user) {
if (!this.socket.userId) {
gameLogger.info('User disconnected but had no user set')
return
}
this.io.emit('user:disconnect', this.socket.user.id)
this.io.emit('user:disconnect', this.socket.userId)
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) {
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
gameLogger.info('User disconnected but had no character set')
return
}
const character = zoneCharacter.character
// Save character position and remove from zone
zoneCharacter.isMoving = false
await zoneCharacter.savePosition()
ZoneManager.removeCharacter(this.socket.characterId!)
gameLogger.info('User disconnected along with their character')
await CharacterManager.removeCharacter(character)
// Inform other clients that the character has left
this.io.in(character.zoneId.toString()).emit('zone:character:leave', character.id)
this.io.emit('character:disconnect', character.id)
} catch (error: any) {
gameLogger.error('disconnect error', error.message)
}
}
}
}

View File

@ -0,0 +1,40 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
import { CharacterGender, CharacterRace } from '@prisma/client'
export default class CharacterTypeCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const newCharacterType = await prisma.characterType.create({
data: {
name: 'New character type',
gender: CharacterGender.MALE,
race: CharacterRace.HUMAN
}
})
callback(true, newCharacterType)
} catch (error) {
console.error('Error creating character type:', error)
callback(false)
}
}
}

View File

@ -0,0 +1,42 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
import { getPublicPath } from '../../../../utilities/storage'
import { gameMasterLogger } from '../../../../utilities/logger'
interface IPayload {
id: number
}
export default class CharacterTypeDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.characterType.delete({
where: { id: data.id }
})
callback(true)
} catch (error) {
gameMasterLogger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -0,0 +1,36 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { CharacterType } from '@prisma/client'
import characterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
import CharacterTypeRepository from '../../../../repositories/characterTypeRepository'
interface IPayload {}
export default class CharacterTypeListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: CharacterType[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:characterType:list error', 'Character not found')
return callback([])
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to list character types but is not a game master.`)
return callback([])
}
// get all objects
const items = await CharacterTypeRepository.getAll()
callback(items)
}
}

View File

@ -0,0 +1,52 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
import { CharacterGender, CharacterRace } from '@prisma/client'
type Payload = {
id: number
name: string
gender: CharacterGender
race: CharacterRace
isEnabledForCharCreation: boolean
spriteId: string
}
export default class CharacterTypeUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterType:update', this.handleObjectUpdate.bind(this))
}
private async handleObjectUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
try {
await prisma.characterType.update({
where: { id: data.id },
data: {
name: data.name,
gender: data.gender,
race: data.race,
isEnabledForCharCreation: data.isEnabledForCharCreation,
spriteId: data.spriteId
}
})
callback(true)
} catch (error) {
console.error(error)
callback(false)
}
}
}

View File

@ -2,19 +2,22 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { Object } from '@prisma/client'
import ObjectRepository from '../../../../repositories/objectRepository'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
interface IPayload {}
/**
* Handle game master list object event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:object:list', async (data: any, callback: (response: Object[]) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class ObjectListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:list', this.handleObjectList.bind(this))
}
private async handleObjectList(data: IPayload, callback: (response: Object[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback([])
if (character.role !== 'gm') {
@ -24,5 +27,5 @@ export default function (io: Server, socket: TSocket) {
// get all objects
const objects = await ObjectRepository.getAll()
callback(objects)
})
}
}

View File

@ -1,23 +1,27 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import path from 'path'
import fs from 'fs'
import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
import { getPublicPath } from '../../../../utilities/storage'
import { gameLogger, gameMasterLogger } from '../../../../utilities/logger'
interface IPayload {
object: string
}
/**
* Handle game master remove object event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:object:remove', async (data: IPayload, callback: (response: boolean) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class ObjectRemoveEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:remove', this.handleObjectRemove.bind(this))
}
private async handleObjectRemove(data: IPayload, callback: (response: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
@ -32,22 +36,22 @@ export default function (io: Server, socket: TSocket) {
})
// get root path
const public_folder = path.join(process.cwd(), 'public', 'objects')
const public_folder = getPublicPath('objects')
// remove the tile from the disk
const finalFilePath = path.join(public_folder, data.object + '.png')
const finalFilePath = getPublicPath('objects', data.object + '.png')
fs.unlink(finalFilePath, (err) => {
if (err) {
console.log(err)
gameMasterLogger.error(`Error deleting object ${data.object}: ${err.message}`)
callback(false)
return
}
callback(true)
})
} catch (e) {
console.log(e)
} catch (error) {
gameLogger.error(`Error deleting object ${data.object}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
})
}
}

View File

@ -1,7 +1,6 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
type Payload = {
@ -16,14 +15,18 @@ type Payload = {
frameHeight: number
}
/**
* Handle game master object update event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:object:update', async (data: Payload, callback: (success: boolean) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class ObjectUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:update', this.handleObjectUpdate.bind(this))
}
private async handleObjectUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
@ -51,5 +54,5 @@ export default function (io: Server, socket: TSocket) {
console.error(error)
callback(false)
}
})
}
}

View File

@ -1,12 +1,12 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { writeFile } from 'node:fs/promises'
import path from 'path'
import fs from 'fs/promises'
import prisma from '../../../../utilities/prisma'
import sharp from 'sharp'
import characterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
import { getPublicPath } from '../../../../utilities/storage'
interface IObjectData {
[key: string]: Buffer
@ -30,7 +30,7 @@ export default class ObjectUploadEvent {
if (character.role !== 'gm') {
return callback(false)
}
const public_folder = path.join(process.cwd(), 'public', 'objects')
const public_folder = getPublicPath('objects')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
@ -54,7 +54,7 @@ export default class ObjectUploadEvent {
const uuid = object.id
const filename = `${uuid}.png`
const finalFilePath = path.join(public_folder, filename)
const finalFilePath = getPublicPath('objects', filename)
await writeFile(finalFilePath, objectData)
gameMasterLogger.info('gm:object:upload', `Object ${key} uploaded with id ${uuid}`)

View File

@ -1,27 +1,30 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import path from 'path'
import fs from 'fs/promises'
import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
import { getPublicPath } from '../../../../utilities/storage'
/**
* Handle game master new sprite event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:sprite:create', async (data: undefined, callback: (response: boolean) => void) => {
export default class SpriteCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:create', this.handleSpriteCreate.bind(this))
}
private async handleSpriteCreate(data: undefined, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(socket.characterId as number)
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback(false)
if (character.role !== 'gm') {
return callback(false)
}
const public_folder = path.join(process.cwd(), 'public', 'sprites')
const public_folder = getPublicPath('sprites')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
@ -34,7 +37,7 @@ export default function (io: Server, socket: TSocket) {
const uuid = sprite.id
// Create folder with uuid
const sprite_folder = path.join(public_folder, uuid)
const sprite_folder = getPublicPath('sprites', uuid)
await fs.mkdir(sprite_folder, { recursive: true })
callback(true)
@ -42,5 +45,5 @@ export default function (io: Server, socket: TSocket) {
console.error('Error creating sprite:', error)
callback(false)
}
})
}
}

View File

@ -1,10 +1,10 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import fs from 'fs'
import path from 'path'
import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import { gameMasterLogger } from '../../../../utilities/logger'
import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
type Payload = {
id: string
@ -17,15 +17,15 @@ export default class GMSpriteDeleteEvent {
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = path.join(process.cwd(), 'public', 'sprites')
this.public_folder = getPublicPath('sprites')
}
public listen(): void {
this.socket.on('gm:sprite:delete', this.handleSpriteDelete.bind(this))
this.socket.on('gm:sprite:delete', this.handleEvent.bind(this))
}
private async handleSpriteDelete(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket)
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') {
return callback(false)
}
@ -43,7 +43,7 @@ export default class GMSpriteDeleteEvent {
}
private async deleteSpriteFolder(spriteId: string): Promise<void> {
const finalFilePath = path.join(this.public_folder, spriteId)
const finalFilePath = getPublicPath('sprites', spriteId)
if (fs.existsSync(finalFilePath)) {
await fs.promises.rmdir(finalFilePath, { recursive: true })

View File

@ -2,19 +2,22 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { Sprite } from '@prisma/client'
import SpriteRepository from '../../../../repositories/spriteRepository'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
interface IPayload {}
/**
* Handle game master list sprite event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:sprite:list', async (data: any, callback: (response: Sprite[]) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class SpriteListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:list', this.handleSpriteList.bind(this))
}
private async handleSpriteList(data: any, callback: (response: Sprite[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback([])
if (character.role !== 'gm') {
@ -24,5 +27,5 @@ export default function (io: Server, socket: TSocket) {
// get all sprites
const sprites = await SpriteRepository.getAll()
callback(sprites)
})
}
}

View File

@ -2,10 +2,11 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import type { Prisma, SpriteAction } from '@prisma/client'
import path from 'path'
import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp'
import CharacterManager from '../../../../managers/characterManager'
import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
sprites: string[]
@ -27,9 +28,18 @@ interface ProcessedSpriteAction extends SpriteActionInput {
}>
}
export default function (io: Server, socket: TSocket) {
socket.on('gm:sprite:update', async (data: Payload, callback: (success: boolean) => void) => {
const character = CharacterManager.getCharacterFromSocket(socket)
export default class SpriteUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this))
}
private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') {
return callback(false)
}
@ -43,104 +53,104 @@ export default function (io: Server, socket: TSocket) {
callback(true)
} catch (error) {
console.error('Error updating sprite:', error)
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
})
}
function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) {
throw new Error('spriteActions is not an array')
}
return parsed
} catch (error) {
console.error('Error parsing spriteActions:', error)
throw error
}
}
async function processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all(
spriteActions.map(async (spriteAction) => {
const { action, sprites } = spriteAction
if (!Array.isArray(sprites) || sprites.length === 0) {
throw new Error(`Invalid sprites array for action: ${action}`)
function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) {
gameMasterLogger.error('Error parsing spriteActions: spriteActions is not an array')
}
return parsed
} catch (error) {
gameMasterLogger.error(`Error parsing spriteActions: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
const buffersWithDimensions = await Promise.all(
sprites.map(async (sprite: string) => {
const buffer = Buffer.from(sprite.split(',')[1], 'base64')
const { width, height } = await sharp(buffer).metadata()
return { buffer, width, height }
async function processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all(
spriteActions.map(async (spriteAction) => {
const { action, sprites } = spriteAction
if (!Array.isArray(sprites) || sprites.length === 0) {
gameMasterLogger.error(`Invalid sprites array for action: ${action}`)
}
const buffersWithDimensions = await Promise.all(
sprites.map(async (sprite: string) => {
const buffer = Buffer.from(sprite.split(',')[1], 'base64')
const { width, height } = await sharp(buffer).metadata()
return { buffer, width, height }
})
)
const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0))
const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0))
return {
...spriteAction,
frameWidth,
frameHeight,
buffersWithDimensions
}
})
)
const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0))
const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0))
return {
...spriteAction,
frameWidth,
frameHeight,
buffersWithDimensions
}
})
)
}
async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) {
await prisma.sprite.update({
where: { id },
data: {
name,
spriteActions: {
deleteMany: { spriteId: id },
create: processedActions.map(({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed }) => ({
action,
sprites,
originX,
originY,
isAnimated,
isLooping,
frameWidth,
frameHeight,
frameSpeed
}))
}
}
})
}
async function saveSpritesToDisk(id: string, processedActions: ProcessedSpriteAction[]) {
const publicFolder = path.join(process.cwd(), 'public', 'sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => {
const combinedImage = await sharp({
create: {
width: frameWidth * buffersWithDimensions.length,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) {
await prisma.sprite.update({
where: { id },
data: {
name,
spriteActions: {
deleteMany: { spriteId: id },
create: processedActions.map(({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed }) => ({
action,
sprites,
originX,
originY,
isAnimated,
isLooping,
frameWidth,
frameHeight,
frameSpeed
}))
}
}
})
.composite(
buffersWithDimensions.map(({ buffer }, index) => ({
input: buffer,
left: index * frameWidth,
top: 0
}))
)
.png()
.toBuffer()
}
const filename = path.join(publicFolder, `${action}.png`)
await writeFile(filename, combinedImage)
})
)
async function saveSpritesToDisk(id: string, processedActions: ProcessedSpriteAction[]) {
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => {
const combinedImage = await sharp({
create: {
width: frameWidth * buffersWithDimensions.length,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite(
buffersWithDimensions.map(({ buffer }, index) => ({
input: buffer,
left: index * frameWidth,
top: 0
}))
)
.png()
.toBuffer()
const filename = getPublicPath('sprites', id, `${action}.png`)
await writeFile(filename, combinedImage)
})
)
}
}
}

View File

@ -1,10 +1,10 @@
import path from 'path'
import fs from 'fs/promises'
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
import { getPublicPath } from '../../../../utilities/storage'
type Payload = {
id: string
@ -17,7 +17,7 @@ export default class GMTileDeleteEvent {
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = path.join(process.cwd(), 'public', 'tiles')
this.public_folder = getPublicPath('tiles')
}
public listen(): void {
@ -54,7 +54,7 @@ export default class GMTileDeleteEvent {
}
private async deleteTileFile(tileId: string): Promise<void> {
const finalFilePath = path.join(this.public_folder, `${tileId}.png`)
const finalFilePath = getPublicPath('tiles', `${tileId}.png`)
try {
await fs.unlink(finalFilePath)
} catch (error: any) {

View File

@ -2,19 +2,22 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { Tile } from '@prisma/client'
import TileRepository from '../../../../repositories/tileRepository'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
interface IPayload {}
/**
* Handle game master list tile event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:tile:list', async (data: any, callback: (response: Tile[]) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class TileListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:list', this.handleTileList.bind(this))
}
private async handleTileList(data: any, callback: (response: Tile[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return
if (character.role !== 'gm') {
@ -24,5 +27,5 @@ export default function (io: Server, socket: TSocket) {
// get all tiles
const tiles = await TileRepository.getAll()
callback(tiles)
})
}
}

View File

@ -1,7 +1,6 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import characterRepository from '../../../../repositories/characterRepository'
type Payload = {
@ -10,14 +9,18 @@ type Payload = {
tags: string[]
}
/**
* Handle game master tile update event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:tile:update', async (data: Payload, callback: (success: boolean) => void) => {
const character = await characterRepository.getById(socket.characterId as number)
export default class TileUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:update', this.handleTileUpdate.bind(this))
}
private async handleTileUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
@ -40,5 +43,5 @@ export default function (io: Server, socket: TSocket) {
console.error(error)
callback(false)
}
})
}
}

View File

@ -1,32 +1,36 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import { writeFile } from 'node:fs/promises'
import path from 'path'
import fs from 'fs/promises'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
import { getPublicPath } from '../../../../utilities/storage'
interface ITileData {
[key: string]: Buffer
}
/**
* Handle game master upload tile event
* @param socket
* @param io
*/
export default function (io: Server, socket: TSocket) {
socket.on('gm:tile:upload', async (data: ITileData, callback: (response: boolean) => void) => {
export default class TileUploadEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:tile:upload', this.handleTileUpload.bind(this))
}
private async handleTileUpload(data: ITileData, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(socket.characterId as number)
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (character.role !== 'gm') {
return
}
const public_folder = path.join(process.cwd(), 'public', 'tiles')
const public_folder = getPublicPath('tiles')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
@ -39,7 +43,7 @@ export default function (io: Server, socket: TSocket) {
})
const uuid = tile.id
const filename = `${uuid}.png`
const finalFilePath = path.join(public_folder, filename)
const finalFilePath = getPublicPath('tiles', filename)
await writeFile(finalFilePath, tileData)
})
@ -50,5 +54,5 @@ export default function (io: Server, socket: TSocket) {
gameMasterLogger.error('Error uploading tile:', error)
callback(false)
}
})
}
}

View File

@ -59,4 +59,4 @@ export default class ZoneCreateEvent {
callback([])
}
}
}
}

View File

@ -58,4 +58,4 @@ export default class ZoneDeleteEvent {
callback(false)
}
}
}
}

View File

@ -41,4 +41,4 @@ export default class ZoneListEvent {
callback([])
}
}
}
}

View File

@ -56,4 +56,4 @@ export default class ZoneRequestEvent {
callback(null)
}
}
}
}

View File

@ -1,7 +1,7 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types'
import ZoneRepository from '../../../repositories/zoneRepository'
import { Zone, ZoneEventTileType, ZoneObject } from '@prisma/client'
import { Zone, ZoneEffect, ZoneEventTileType, ZoneObject } from '@prisma/client'
import prisma from '../../../utilities/prisma'
import zoneManager from '../../../managers/zoneManager'
import CharacterRepository from '../../../repositories/characterRepository'
@ -22,8 +22,13 @@ interface IPayload {
toZoneId: number
toPositionX: number
toPositionY: number
toRotation: number
}
}[]
zoneEffects: {
effect: string
strength: number
}[]
zoneObjects: ZoneObject[]
}
@ -34,40 +39,52 @@ export default class ZoneUpdateEvent {
) {}
public listen(): void {
this.socket.on('gm:zone_editor:zone:update', this.handleZoneUpdate.bind(this))
this.socket.on('gm:zone_editor:zone:update', this.handleEvent.bind(this))
}
private async handleZoneUpdate(data: IPayload, callback: (response: Zone | null) => void): Promise<void> {
private async handleEvent(data: IPayload, callback: (response: Zone | null) => void): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:update error', 'Character not found')
callback(null)
return
return callback(null)
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to update zone but is not a game master.`)
callback(null)
return
return callback(null)
}
gameMasterLogger.info(`User ${character.id} has updated zone via zone editor.`)
if (!data.zoneId) {
gameMasterLogger.info(`User ${character.id} tried to update zone but did not provide a zone id.`)
callback(null)
return
return callback(null)
}
let zone = await ZoneRepository.getById(data.zoneId)
if (!zone) {
gameMasterLogger.info(`User ${character.id} tried to update zone ${data.zoneId} but it does not exist.`)
callback(null)
return
return callback(null)
}
// If tiles are larger than the zone, remove the extra tiles
if (data.tiles.length > data.height) {
data.tiles = data.tiles.slice(0, data.height)
}
for (let i = 0; i < data.tiles.length; i++) {
if (data.tiles[i].length > data.width) {
data.tiles[i] = data.tiles[i].slice(0, data.width)
}
}
// If zone event tiles are placed outside the zone's bounds, remove these
data.zoneEventTiles = data.zoneEventTiles.filter((tile) => tile.positionX >= 0 && tile.positionX < data.width && tile.positionY >= 0 && tile.positionY < data.height)
// If zone objects are placed outside the zone's bounds, remove these
data.zoneObjects = data.zoneObjects.filter((obj) => obj.positionX >= 0 && obj.positionX < data.width && obj.positionY >= 0 && obj.positionY < data.height)
await prisma.zone.update({
where: { id: data.zoneId },
data: {
@ -84,14 +101,15 @@ export default class ZoneUpdateEvent {
positionY: zoneEventTile.positionY,
...(zoneEventTile.type === 'TELEPORT' && zoneEventTile.teleport
? {
teleport: {
create: {
toZoneId: zoneEventTile.teleport.toZoneId,
toPositionX: zoneEventTile.teleport.toPositionX,
toPositionY: zoneEventTile.teleport.toPositionY
teleport: {
create: {
toZoneId: zoneEventTile.teleport.toZoneId,
toPositionX: zoneEventTile.teleport.toPositionX,
toPositionY: zoneEventTile.teleport.toPositionY,
toRotation: zoneEventTile.teleport.toRotation
}
}
}
}
: {})
}))
},
@ -100,10 +118,19 @@ export default class ZoneUpdateEvent {
create: data.zoneObjects.map((zoneObject) => ({
objectId: zoneObject.objectId,
depth: zoneObject.depth,
isRotated: zoneObject.isRotated,
positionX: zoneObject.positionX,
positionY: zoneObject.positionY
}))
}
},
zoneEffects: {
deleteMany: { zoneId: data.zoneId },
create: data.zoneEffects.map((zoneEffect) => ({
effect: zoneEffect.effect,
strength: zoneEffect.strength
}))
},
updatedAt: new Date()
}
})
@ -115,13 +142,18 @@ export default class ZoneUpdateEvent {
return
}
gameMasterLogger.info(`User ${character.id} has updated zone via zone editor.`)
callback(zone)
/**
* @TODO #246: Reload zone for players who are currently in the zone
*/
zoneManager.unloadZone(data.zoneId)
await zoneManager.loadZone(zone)
} catch (error: any) {
gameMasterLogger.error('gm:zone_editor:zone:update error', error.message)
gameMasterLogger.error(`gm:zone_editor:zone:update error: ${error instanceof Error ? error.message : String(error)}`)
callback(null)
}
}
}
}

View File

@ -1,6 +1,7 @@
import { Server } from 'socket.io'
import { TSocket } from '../utilities/types'
import { gameLogger } from '../utilities/logger'
import UserRepository from '../repositories/userRepository'
export default class LoginEvent {
constructor(
@ -14,15 +15,15 @@ export default class LoginEvent {
private handleLogin(): void {
try {
if (!this.socket.user) {
if (!this.socket.userId) {
gameLogger.warn('Login attempt without user data')
return
}
this.socket.emit('logged_in', { user: this.socket.user })
gameLogger.info(`User logged in: ${this.socket.user.id}`)
this.socket.emit('logged_in', { user: UserRepository.getById(this.socket.userId) })
gameLogger.info(`User logged in: ${this.socket.userId}`)
} catch (error: any) {
gameLogger.error('login error', error.message)
}
}
}
}

View File

@ -1,13 +1,16 @@
import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import ZoneRepository from '../../repositories/zoneRepository'
import { Character, Zone } from '@prisma/client'
import CharacterManager from '../../managers/characterManager'
import { Zone } from '@prisma/client'
import { gameLogger } from '../../utilities/logger'
import CharacterRepository from '../../repositories/characterRepository'
import ZoneManager from '../../managers/zoneManager'
import zoneCharacter from '../../models/zoneCharacter'
import zoneManager from '../../managers/zoneManager'
interface IResponse {
zone: Zone
characters: Character[]
characters: zoneCharacter[]
}
export default class CharacterJoinEvent {
@ -22,32 +25,50 @@ export default class CharacterJoinEvent {
private async handleCharacterJoin(callback: (response: IResponse) => void): Promise<void> {
try {
if (!this.socket.characterId) return
if (!this.socket.characterId) {
gameLogger.error('zone:character:join error', 'Zone requested but no character id set')
return
}
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) return
const character = await CharacterRepository.getById(this.socket.characterId)
if (!character) {
gameLogger.error('zone:character:join error', 'Character not found')
return
}
/**
* @TODO: If zone is not found, spawn back to the start
*/
const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) {
gameLogger.error('zone:character:join error', 'Zone not found')
return
}
if (character.zoneId) {
this.socket.leave(character.zoneId.toString())
this.io.to(character.zoneId.toString()).emit('zone:character:leave', character)
/**
* @TODO: If zone is not found, spawn back to the start
*/
const loadedZone = ZoneManager.getZoneById(zone.id)
if (!loadedZone) {
gameLogger.error('zone:character:join error', 'Loaded zone not found')
return
}
loadedZone.addCharacter(character)
this.socket.join(zone.id.toString())
// let other clients know of new character
this.io.to(zone.id.toString()).emit('zone:character:join', character)
// Let other clients know of new character
this.io.to(zone.id.toString()).emit('zone:character:join', zoneManager.getCharacter(character.id))
// send over zone and characters to socket
callback({ zone, characters: CharacterManager.getCharactersInZone(zone) })
// Log
gameLogger.info(`User ${character.id} joined zone ${zone.id}`)
// Send over zone and characters to socket
callback({ zone, characters: loadedZone.getCharactersInZone() })
} catch (error: any) {
gameLogger.error('zone:character:join error', error.message)
this.socket.disconnect()
}
}
}
}

View File

@ -1,8 +1,9 @@
import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import ZoneRepository from '../../repositories/zoneRepository'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger'
import ZoneManager from '../../managers/zoneManager'
import CharacterRepository from '../../repositories/characterRepository'
export default class ZoneLeaveEvent {
constructor(
@ -16,21 +17,29 @@ export default class ZoneLeaveEvent {
private async handleZoneLeave(): Promise<void> {
try {
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!this.socket.characterId) {
gameLogger.error('zone:character:join error', 'Zone requested but no character id set')
return
}
const character = await CharacterRepository.getById(this.socket.characterId)
if (!character) {
gameLogger.error('zone:character:leave error', 'Character not found')
return
}
if (!character.zoneId) {
gameLogger.error('zone:character:leave error', 'Character not in a zone')
gameLogger.error('zone:character:join error', 'Character not found')
return
}
/**
* @TODO: If zone is not found, spawn back to the start
*/
const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) {
gameLogger.error('zone:character:leave error', 'Zone not found')
gameLogger.error('zone:character:join error', 'Zone not found')
return
}
const loadedZone = ZoneManager.getZoneById(zone.id)
if (!loadedZone) {
gameLogger.error('zone:character:join error', 'Loaded zone not found')
return
}
@ -40,7 +49,7 @@ export default class ZoneLeaveEvent {
this.io.to(zone.id.toString()).emit('zone:character:leave', character.id)
// remove character from zone manager
await CharacterManager.removeCharacter(character)
await loadedZone.removeCharacter(character.id)
gameLogger.info('zone:character:leave', `Character ${character.id} left zone ${zone.id}`)
} catch (error: any) {

View File

@ -1,144 +1,96 @@
import { Server } from 'socket.io'
import { TSocket, ExtendedCharacter } from '../../utilities/types'
import { CharacterMoveService } from '../../services/character/characterMoveService'
import { TSocket, ZoneEventTileWithTeleport } from '../../utilities/types'
import { CharacterService } from '../../services/character/characterService'
import { ZoneEventTileService } from '../../services/zoneEventTileService'
import prisma from '../../utilities/prisma'
import { ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client'
import Rotation from '../../utilities/character/rotation'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger'
import QueueManager from '../../managers/queueManager'
export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
}
import ZoneManager from '../../managers/zoneManager'
import ZoneCharacter from '../../models/zoneCharacter'
import zoneEventTileRepository from '../../repositories/zoneEventTileRepository'
export default class CharacterMove {
private characterMoveService: CharacterMoveService
private zoneEventTileService: ZoneEventTileService
private nextPath: { [index: number]: { x: number; y: number }[] } = []
private currentZoneId: { [index: number]: number } = []
private readonly characterService = new CharacterService()
private readonly zoneEventTileService = new ZoneEventTileService()
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {
this.characterMoveService = new CharacterMoveService()
this.zoneEventTileService = new ZoneEventTileService()
}
) {}
public listen(): void {
this.socket.on('character:initMove', this.handleCharacterMove.bind(this))
this.socket.on('character:move', this.handleCharacterMove.bind(this))
}
private async handleCharacterMove({ positionX, positionY }: { positionX: number; positionY: number }): Promise<void> {
let character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) {
gameLogger.error('character:move error', 'Character not found')
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter?.character) {
gameLogger.error('character:move error', 'Character not found or not initialized')
return
}
if (!character) {
gameLogger.error('character:move error', 'character has not been initialized?')
return
// If already moving, cancel current movement and wait for it to fully stop
if (zoneCharacter.isMoving) {
zoneCharacter.isMoving = false
await new Promise((resolve) => setTimeout(resolve, 100))
}
const path = await this.characterMoveService.calculatePath(character, positionX, positionY)
const path = await this.characterService.calculatePath(zoneCharacter.character, positionX, positionY)
if (!path) {
this.io.in(character.zoneId.toString()).emit('character:moveError', 'No valid path found')
this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:moveError', 'No valid path found')
return
}
if (!character.isMoving && character.resetMovement) {
character.resetMovement = false
}
if (character.isMoving && !character.resetMovement) {
character.resetMovement = true
this.nextPath[character.id] = path
}
if (!character.isMoving && !character.resetMovement) {
character.isMoving = true
this.currentZoneId[character.id] = character.zoneId
await this.moveAlongPath(character, path)
}
// Start new movement
zoneCharacter.isMoving = true
zoneCharacter.currentPath = path // Add this property to ZoneCharacter class
await this.moveAlongPath(zoneCharacter, path)
}
private async moveAlongPath(character: ExtendedCharacter, path: Array<{ x: number; y: number }>): Promise<void> {
private async moveAlongPath(zoneCharacter: ZoneCharacter, path: Array<{ x: number; y: number }>): Promise<void> {
const { character } = zoneCharacter
for (let i = 0; i < path.length - 1; i++) {
const start = path[i]
const end = path[i + 1]
// Exit if movement was cancelled or interrupted
if (!zoneCharacter.isMoving || zoneCharacter.currentPath !== path) {
return
}
// if (!(await this.movementValidator.isValidMove(character, end))) {
// break
// }
const [start, end] = [path[i], path[i + 1]]
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y)
if (CharacterManager.hasResetMovement(character)) {
const zoneEventTile = await zoneEventTileRepository.getEventTileByZoneIdAndPosition(character.zoneId, Math.floor(end.x), Math.floor(end.y))
if (zoneEventTile?.type === 'BLOCK') break
if (zoneEventTile?.type === 'TELEPORT' && zoneEventTile.teleport) {
await this.handleZoneEventTile(zoneEventTile as ZoneEventTileWithTeleport)
break
}
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y)
const zoneEventTile = await prisma.zoneEventTile.findFirst({
where: {
zoneId: character.zoneId,
positionX: Math.floor(end.x),
positionY: Math.floor(end.y)
}
})
if (zoneEventTile) {
if (zoneEventTile.type === 'BLOCK') {
break
}
if (zoneEventTile.type === 'TELEPORT') {
const teleportTile = (await prisma.zoneEventTile.findFirst({
where: { id: zoneEventTile.id },
include: { teleport: true }
})) as ZoneEventTileWithTeleport
if (teleportTile) {
await this.handleZoneEventTile(teleportTile)
break
}
}
}
this.characterMoveService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', character)
await this.characterMoveService.applyMovementDelay()
this.characterService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', zoneCharacter)
await this.characterService.applyMovementDelay()
}
if (CharacterManager.hasResetMovement(character)) {
character.resetMovement = false
if (this.currentZoneId[character.id] === character.zoneId) {
await this.moveAlongPath(character, this.nextPath[character.id])
} else {
delete this.currentZoneId[character.id]
character.isMoving = false
}
} else {
this.finalizeMovement(character)
// Only finalize if this path wasn't interrupted
if (zoneCharacter.isMoving && zoneCharacter.currentPath === path) {
this.finalizeMovement(zoneCharacter)
}
}
private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) {
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
gameLogger.error('character:move error', 'Character not found')
return
}
const teleport = zoneEventTile.teleport
if (teleport) {
await this.zoneEventTileService.handleTeleport(this.io, this.socket, character, teleport)
return
if (zoneEventTile.teleport) {
await this.zoneEventTileService.handleTeleport(this.io, this.socket, zoneCharacter.character, zoneEventTile.teleport)
}
}
private finalizeMovement(character: ExtendedCharacter): void {
character.isMoving = false
this.io.in(character.zoneId.toString()).emit('character:move', character)
private finalizeMovement(zoneCharacter: ZoneCharacter): void {
zoneCharacter.isMoving = false
this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:move', zoneCharacter)
}
}

View File

@ -0,0 +1,24 @@
import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types'
import { gameLogger } from '../../utilities/logger'
import WeatherManager from '../../managers/weatherManager'
export default class Weather {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('weather', this.handleEvent.bind(this))
}
private async handleEvent(): Promise<void> {
try {
const weather = await WeatherManager.getWeatherState()
this.socket.emit('weather', weather)
} catch (error: any) {
gameLogger.error('error', error.message)
}
}
}

View File

@ -2,33 +2,31 @@ import config from '../config'
class Rotation {
static calculate(X1: number, Y1: number, X2: number, Y2: number): number {
let rotation = 0
if (config.ALLOW_DIAGONAL_MOVEMENT) {
// Check diagonal movements
if (X1 > X2 && Y1 > Y2) {
rotation = 7
return 7
} else if (X1 < X2 && Y1 < Y2) {
rotation = 3
return 3
} else if (X1 > X2 && Y1 < Y2) {
rotation = 5
return 5
} else if (X1 < X2 && Y1 > Y2) {
rotation = 1
return 1
}
}
if (rotation === 0) {
if (X1 > X2) {
rotation = 6
} else if (X1 < X2) {
rotation = 2
} else if (Y1 < Y2) {
rotation = 4
} else if (Y1 > Y2) {
rotation = 0
}
// Non-diagonal movements
if (X1 > X2) {
return 6
} else if (X1 < X2) {
return 2
} else if (Y1 < Y2) {
return 4
} else if (Y1 > Y2) {
return 0
}
return rotation
return 0 // Default case
}
}

View File

@ -1,11 +1,11 @@
export function isCommand(message: string, command?: string) {
if (command) {
return message.startsWith(`:${command} `)
return message === `/${command}` || message.startsWith(`/${command} `)
}
return message.startsWith(':')
return message.startsWith('/')
}
export function getArgs(command: string, message: string): string[] | undefined {
if (!isCommand(message, command)) return
return message.split(`:${command} `)[1].split(' ')
return message.split(`/${command} `)[1].split(' ')
}

View File

@ -7,6 +7,7 @@ class config {
static REDIS_URL: string = process.env.REDIS_URL || 'redis://@127.0.0.1:6379/4'
static HOST: string = process.env.HOST || '0.0.0.0'
static PORT: number = process.env.PORT ? parseInt(process.env.PORT) : 6969
static CLIENT_URL: string = process.env.CLIENT_URL ? process.env.CLIENT_URL : 'https://sylvan.quest'
static JWT_SECRET: string = process.env.JWT_SECRET || 'secret'
static ALLOW_DIAGONAL_MOVEMENT: boolean = process.env.ALLOW_DIAGONAL_MOVEMENT === 'true'
@ -14,6 +15,11 @@ class config {
static DEFAULT_CHARACTER_ZONE: number = parseInt(process.env.DEFAULT_CHARACTER_ZONE || '1')
static DEFAULT_CHARACTER_X: number = parseInt(process.env.DEFAULT_CHARACTER_POS_X || '0')
static DEFAULT_CHARACTER_Y: number = parseInt(process.env.DEFAULT_CHARACTER_POS_Y || '0')
static SMTP_HOST: string = process.env.SMTP_HOST || 'my.directonline.io'
static SMTP_PORT: number = process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : 587
static SMTP_USER: string = process.env.SMTP_USER || 'no-reply@sylvan.quest'
static SMTP_PASSWORD: string = process.env.SMTP_PASSWORD || 'password'
}
export default config

View File

@ -2,138 +2,16 @@ import { Application, Request, Response } from 'express'
import UserService from '../services/userService'
import jwt from 'jsonwebtoken'
import config from './config'
import { loginAccountSchema, registerAccountSchema } from './zodTypes'
import path from 'path'
import { TAsset } from './types'
import tileRepository from '../repositories/tileRepository'
import objectRepository from '../repositories/objectRepository'
import spriteRepository from '../repositories/spriteRepository'
import { loginAccountSchema, registerAccountSchema, resetPasswordSchema, newPasswordSchema } from './zodTypes'
import fs from 'fs'
import zoneRepository from '../repositories/zoneRepository'
import zoneManager from '../managers/zoneManager'
import { httpLogger } from './logger'
import { getPublicPath } from './storage'
import TileRepository from '../repositories/tileRepository'
import { AssetData } from './types'
import ZoneRepository from '../repositories/zoneRepository'
import SpriteRepository from '../repositories/spriteRepository'
async function addHttpRoutes(app: Application) {
/**
* Get all base sprite, assets
* @param req
* @param res
*/
app.get('/assets/sprites', async (req: Request, res: Response) => {
let assets: TAsset[] = []
const sprites = await spriteRepository.getAll()
// sprites all contain spriteActions, loop through these
sprites.forEach((sprite) => {
sprite.spriteActions.forEach((spriteAction) => {
assets.push({
key: sprite.id + '-' + spriteAction.action,
url: '/assets/sprites/' + sprite.id + '/' + spriteAction.action + '.png',
group: spriteAction.isAnimated ? 'sprite_animations' : 'sprites',
frameWidth: spriteAction.frameWidth,
frameHeight: spriteAction.frameHeight
})
})
})
res.json(assets)
})
/**
* Get all assets for all zones
* @param req
* @param res
*/
app.get('/assets/zone', async (req: Request, res: Response) => {
const tiles = await tileRepository.getAll()
const objects = await objectRepository.getAll()
const assets: TAsset[] = []
tiles.forEach((tile) => {
assets.push({
key: tile.id,
url: '/assets/tiles/' + tile.id + '.png',
group: 'tiles'
})
})
objects.forEach((object) => {
assets.push({
key: object.id,
url: '/assets/objects/' + object.id + '.png',
group: 'objects'
})
})
res.json(assets)
})
/**
* Get assets for a specific zone
* @param req
* @param res
*/
app.get('/assets/zone/:zoneId', async (req: Request, res: Response) => {
const zoneId = req.params.zoneId
if (!zoneId || parseInt(zoneId) === 0) {
return res.status(400).json({ message: 'Invalid zone ID' })
}
const zone = await zoneRepository.getById(parseInt(zoneId))
if (!zone) {
return res.status(404).json({ message: 'Zone not found' })
}
const assets = await zoneManager.getZoneAssets(zone)
res.json([
...assets.tiles.map((x) => {
return {
key: x,
url: '/assets/tiles/' + x + '.png',
group: 'tiles'
}
}),
...assets.objects.map((x) => {
return {
key: x,
url: '/assets/objects/' + x + '.png',
group: 'objects'
}
})
])
})
/**
* Get a specific asset
* @param req
* @param res
*/
app.get('/assets/:type/:spriteId?/:file', (req: Request, res: Response) => {
const assetType = req.params.type
const spriteId = req.params.spriteId
const fileName = req.params.file
let assetPath
if (assetType === 'sprites' && spriteId) {
assetPath = path.join(process.cwd(), 'public', assetType, spriteId, fileName)
} else {
assetPath = path.join(process.cwd(), 'public', assetType, fileName)
}
if (!fs.existsSync(assetPath)) {
httpLogger.error(`File not found: ${assetPath}`)
return res.status(404).send('Asset not found')
}
res.sendFile(assetPath, (err) => {
if (err) {
httpLogger.error('Error sending file:', err)
res.status(500).send('Error downloading the asset')
}
})
})
/**
* Login
* @param req
@ -165,16 +43,16 @@ async function addHttpRoutes(app: Application) {
* @param res
*/
app.post('/register', async (req: Request, res: Response) => {
const { username, password } = req.body
const { username, email, password } = req.body
try {
registerAccountSchema.parse({ username, password })
registerAccountSchema.parse({ username, email, password })
} catch (error: any) {
return res.status(400).json({ message: error.errors[0]?.message })
}
const userService = new UserService()
const user = await userService.register(username, password)
const user = await userService.register(username, email, password)
if (user) {
return res.status(200).json({ message: 'User registered' })
@ -183,6 +61,171 @@ async function addHttpRoutes(app: Application) {
return res.status(400).json({ message: 'Failed to register user' })
})
/**
* Reset password
* @param req
* @param res
*/
app.post('/reset-password', async (req: Request, res: Response) => {
const { email } = req.body
try {
resetPasswordSchema.parse({ email })
} catch (error: any) {
return res.status(400).json({ message: error.errors[0]?.message })
}
const userService = new UserService()
const sentEmail = await userService.requestPasswordReset(email)
if (sentEmail) {
return res.status(200).json({ message: 'Email has been sent' })
}
return res.status(400).json({ message: 'Failed to send password reset request. Perhaps one has already been sent recently, check your spam folder.' })
})
/**
* New password
* @param req
* @param res
*/
app.post('/new-password', async (req: Request, res: Response) => {
const { urlToken, password } = req.body
try {
newPasswordSchema.parse({ urlToken, password })
} catch (error: any) {
return res.status(400).json({ message: error.errors[0]?.message })
}
const userService = new UserService()
const resetPassword = await userService.resetPassword(urlToken, password)
if (resetPassword) {
return res.status(200).json({ message: 'Password has been reset' })
}
return res.status(400).json({ message: 'Failed to set new password' })
})
/**
* Get all tiles from a zone as an array of ids
* @param req
* @param res
*/
app.get('/assets/list_tiles', async (req: Request, res: Response) => {
// Get all tiles
let assets: AssetData[] = []
const tiles = await TileRepository.getAll()
for (const tile of tiles) {
assets.push({
key: tile.id,
data: '/assets/tiles/' + tile.id + '.png',
group: 'tiles',
updatedAt: tile.updatedAt
} as AssetData)
}
// Return the array
res.json(assets)
})
/**
* Get all tiles from a zone and serve as AssetData array
* @param req
* @param res
*/
app.get('/assets/list_tiles/:zoneId', async (req: Request, res: Response) => {
const zoneId = req.params.zoneId
// Check if zoneId is valid number
if (!zoneId || parseInt(zoneId) === 0) {
return res.status(400).json({ message: 'Invalid zone ID' })
}
// Get zone by id
const zone = await ZoneRepository.getById(parseInt(zoneId))
if (!zone) {
return res.status(404).json({ message: 'Zone not found' })
}
// Get all tiles
let assets: AssetData[] = []
const tiles = await TileRepository.getByZoneId(parseInt(zoneId))
for (const tile of tiles) {
assets.push({
key: tile.id,
data: '/assets/tiles/' + tile.id + '.png',
group: 'tiles',
updatedAt: tile.updatedAt
} as AssetData)
}
// Return the array
res.json(assets)
})
app.get('/assets/list_sprite_actions/:spriteId', async (req: Request, res: Response) => {
const spriteId = req.params.spriteId
// Check if spriteId is valid number
if (!spriteId || parseInt(spriteId) === 0) {
return res.status(400).json({ message: 'Invalid sprite ID' })
}
// Get sprite by id
const sprite = await SpriteRepository.getById(spriteId)
if (!sprite) {
return res.status(404).json({ message: 'Sprite not found' })
}
let assets: AssetData[] = []
sprite.spriteActions.forEach((spriteAction) => {
assets.push({
key: sprite.id + '-' + spriteAction.action,
data: '/assets/sprites/' + sprite.id + '/' + spriteAction.action + '.png',
group: spriteAction.isAnimated ? 'sprite_animations' : 'sprites',
updatedAt: sprite.updatedAt,
isAnimated: spriteAction.isAnimated,
frameCount: JSON.parse(JSON.stringify(spriteAction.sprites)).length,
frameWidth: spriteAction.frameWidth,
frameHeight: spriteAction.frameHeight
})
})
// Return the array
res.json(assets)
})
/**
* Download asset file
* @param req
* @param res
*/
app.get('/assets/:type/:spriteId?/:file', (req: Request, res: Response) => {
const assetType = req.params.type
const spriteId = req.params.spriteId
const fileName = req.params.file
let assetPath
if (assetType === 'sprites' && spriteId) {
assetPath = getPublicPath(assetType, spriteId, fileName)
} else {
assetPath = getPublicPath(assetType, fileName)
}
if (!fs.existsSync(assetPath)) {
httpLogger.error(`File not found: ${assetPath}`)
return res.status(404).send('Asset not found')
}
res.sendFile(assetPath, (err) => {
if (err) {
httpLogger.error('Error sending file:', err)
res.status(500).send('Error downloading the asset')
}
})
})
httpLogger.info('Web routes added')
}

View File

@ -1,9 +1,9 @@
import pino from 'pino'
import fs from 'fs'
import path from 'path'
import { getRootPath } from './storage'
// Array of log types
const LOG_TYPES = ['http', 'game', 'gameMaster', 'app', 'queue'] as const
const LOG_TYPES = ['http', 'game', 'gameMaster', 'app', 'queue', 'command'] as const
type LogType = (typeof LOG_TYPES)[number]
const createLogger = (name: LogType) =>
@ -30,19 +30,37 @@ const loggers = Object.fromEntries(LOG_TYPES.map((type) => [type, createLogger(t
const watchLogs = () => {
LOG_TYPES.forEach((type) => {
const logFile = path.join(__dirname, '../../logs', `${type}.log`)
const logFile = getRootPath('logs', `${type}.log`)
fs.watchFile(logFile, (curr, prev) => {
if (curr.size > prev.size) {
const stream = fs.createReadStream(logFile, { start: prev.size, end: curr.size })
stream.on('data', (chunk) => {
console.log(`[${type}]\n${chunk.toString()}`)
})
// Get initial file size
const stats = fs.statSync(logFile)
let lastPosition = stats.size
fs.watch(logFile, (eventType) => {
if (eventType !== 'change') {
return
}
fs.stat(logFile, (err, stats) => {
if (err) return
if (stats.size > lastPosition) {
const stream = fs.createReadStream(logFile, {
start: lastPosition,
end: stats.size
})
stream.on('data', (chunk) => {
console.log(`[${type}]\n${chunk.toString()}`)
})
lastPosition = stats.size
}
})
})
})
}
export const { http: httpLogger, game: gameLogger, gameMaster: gameMasterLogger, app: appLogger, queue: queueLogger } = loggers
export const { http: httpLogger, game: gameLogger, gameMaster: gameMasterLogger, app: appLogger, queue: queueLogger, command: commandLogger } = loggers
export { watchLogs }

33
src/utilities/storage.ts Normal file
View File

@ -0,0 +1,33 @@
import config from './config'
import path from 'path'
import fs from 'fs'
export function getRootPath(folder: string, ...additionalSegments: string[]) {
return path.join(process.cwd(), folder, ...additionalSegments)
}
export function getAppPath(folder: string, ...additionalSegments: string[]) {
const baseDir = config.ENV === 'development' ? 'src' : 'dist'
return path.join(process.cwd(), baseDir, folder, ...additionalSegments)
}
export function getPublicPath(folder: string, ...additionalSegments: string[]) {
return path.join(process.cwd(), 'public', folder, ...additionalSegments)
}
export function doesPathExist(path: string) {
try {
fs.accessSync(path, fs.constants.F_OK)
return true
} catch (e) {
return false
}
}
export function createDir(path: string) {
try {
fs.mkdirSync(path, { recursive: true })
} catch (e) {
console.error(e)
}
}

View File

@ -1,8 +1,8 @@
import { Socket } from 'socket.io'
import { Character, User } from '@prisma/client'
import { Character, User, ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client'
export type TSocket = Socket & {
user?: User
userId?: number
characterId?: number
handshake?: {
query?: {
@ -18,17 +18,31 @@ export type TSocket = Socket & {
export type ExtendedCharacter = Character & {
isMoving?: boolean
resetMovement: boolean
resetMovement?: boolean
}
export type TAsset = {
export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
}
export type AssetData = {
key: string
url: string
data: string
group: 'tiles' | 'objects' | 'sprites' | 'sprite_animations' | 'sound' | 'music' | 'ui' | 'font' | 'other'
updatedAt: Date
isAnimated?: boolean
frameCount?: number
frameWidth?: number
frameHeight?: number
}
export type WorldSettings = {
date: Date
isRainEnabled: boolean
isFogEnabled: boolean
fogDensity: number
}
// export type TCharacter = Socket & {
// user?: User
// character?: Character

View File

@ -0,0 +1,3 @@
export function unduplicateArray(array: any[]) {
return [...new Set(array.flat())]
}

View File

@ -20,6 +20,29 @@ export const registerAccountSchema = z.object({
.min(3, { message: 'Name must be at least 3 characters long' })
.max(255, { message: 'Name must be at most 255 characters long' })
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/, { message: 'Name must start with a letter and can only contain letters, numbers, underscores, or dashes' }),
email: z
.string()
.min(3, { message: 'Email must be at least 3 characters long' })
.max(255, { message: 'Email must be at most 255 characters long' })
.regex(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/, { message: 'Email must be valid' }),
password: z
.string()
.min(8, {
message: 'Password must be at least 8 characters long'
})
.max(255)
})
export const resetPasswordSchema = z.object({
email: z
.string()
.min(3, { message: 'Email must be at least 3 characters long' })
.max(255, { message: 'Email must be at most 255 characters long' })
.regex(/^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/, { message: 'Email must be valid' })
})
export const newPasswordSchema = z.object({
urlToken: z.string().min(10, { message: 'Invalid request' }).max(255, { message: 'Invalid request' }),
password: z
.string()
.min(8, {

9
src/utilities/zone.ts Normal file
View File

@ -0,0 +1,9 @@
export function FlattenZoneArray(tiles: string[][]) {
const normalArray = []
for (const row of tiles) {
normalArray.push(...row)
}
return normalArray
}