1
0
forked from noxious/server

Compare commits

...

80 Commits

Author SHA1 Message Date
743d4594df 🎃 2024-12-20 23:13:55 +01:00
2be49c010f Horror code 2024-12-20 21:29:19 +01:00
2ac9416fe6 senior dev moment, finished spritesheet generator 2024-12-20 20:34:39 +01:00
43fe6ab33e Better sprite-sheet generating 2024-12-20 01:28:36 +01:00
1cbf116ad4 🤠🔫 2024-12-18 03:05:11 +01:00
3f10b03d24 Improved table names 2024-12-17 17:07:56 +01:00
a525d80530 New migration file 2024-12-17 17:01:10 +01:00
4748044ab3 Added equipment tables and columns in game & user schema 2024-12-17 16:59:52 +01:00
3b0138130b Removed extra lines that made no differenec 2024-12-15 21:16:33 +01:00
54c75896f9 Put all images in a container size as big as the biggest sprite that was found 2024-12-15 20:57:54 +01:00
9467797dc9 Aids 2024-12-15 20:25:30 +01:00
a8934f8e40 Spritesheet generator improvements 2024-12-15 17:39:47 +01:00
65cae5d824 Spritesheet gen. optimisations 2024-12-15 14:41:00 +01:00
179ccdbc55 Improved texture generation 2024-12-12 00:43:34 +01:00
ff39628f0c Solved teleporting issue 2024-12-07 23:06:59 +01:00
d4680b198e npm update 2024-12-05 09:56:28 +01:00
550b961505 Removed func. 2024-11-24 20:11:52 +01:00
1839bd9a22 Renamed hair > characterHair 2024-11-24 15:13:28 +01:00
1017013032 Moved service logic from repo to service, minor improvements, working hair customisation proof of concept 2024-11-23 16:48:07 +01:00
d5c7cd0294 Added CRUD logic for character hair, made some minor improvements, npm update 2024-11-23 15:30:11 +01:00
4a62bbb118 #245 & #254: Started working on character hair sprite management & character customisation 2024-11-21 02:58:25 +01:00
40c7f6289a npm update 2024-11-20 21:19:42 +01:00
72d731c6f2 OOP improvement 2024-11-17 21:21:34 +01:00
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
77 changed files with 2705 additions and 1116 deletions

View File

@ -4,6 +4,7 @@ PORT=4000
DATABASE_URL="mysql://root@localhost:3306/nq" DATABASE_URL="mysql://root@localhost:3306/nq"
REDIS_URL="redis://@127.0.0.1:6379/4" REDIS_URL="redis://@127.0.0.1:6379/4"
JWT_SECRET="secret" JWT_SECRET="secret"
CLIENT_URL="http://localhost:5173"
# Game configuration # Game configuration
ALLOW_DIAGONAL_MOVEMENT=false ALLOW_DIAGONAL_MOVEMENT=false
@ -11,4 +12,10 @@ ALLOW_DIAGONAL_MOVEMENT=false
# Default character create values # Default character create values
DEFAULT_CHARACTER_ZONE="0" DEFAULT_CHARACTER_ZONE="0"
DEFAULT_CHARACTER_POS_X="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=""

439
package-lock.json generated
View File

@ -14,11 +14,11 @@
"express": "^4.19.2", "express": "^4.19.2",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.15",
"pino": "^9.3.2", "pino": "^9.3.2",
"prisma": "^5.17.0", "prisma": "^5.17.0",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -27,14 +27,17 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/nodemailer": "^6.4.16",
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
"prettier": "^3.3.3" "prettier": "^3.3.3",
"ts-node": "^10.9.2"
} }
}, },
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/trace-mapping": "0.3.9" "@jridgewell/trace-mapping": "0.3.9"
@ -424,6 +427,7 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -433,12 +437,14 @@
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9", "version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/resolve-uri": "^3.0.3",
@ -524,9 +530,9 @@
] ]
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.21.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
"integrity": "sha512-3n+GgbAZYjaS/k0M03yQsQfR1APbr411r74foknnsGpmhNKBG49VuUkxIU6jORgvJPChoD4WC4PqoHImN1FP0w==", "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -542,48 +548,48 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.21.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
"integrity": "sha512-uY8SAhcnORhvgtOrNdvWS98Aq/nkQ9QDUxrWAgW8XrCZaI3j2X7zb7Xe6GQSh6xSesKffFbFlkw0c2luHQviZA==", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.21.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
"integrity": "sha512-hGVTldUkIkTwoV8//hmnAAiAchi4oMEKD3aW5H2RrnI50tTdwza7VQbTTAyN3OIHWlK5DVg6xV7X8N/9dtOydA==", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.21.1", "@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/fetch-engine": "5.21.1", "@prisma/fetch-engine": "5.22.0",
"@prisma/get-platform": "5.21.1" "@prisma/get-platform": "5.22.0"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
"version": "5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36", "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
"integrity": "sha512-qvnEflL0//lh44S/T9NcvTMxfyowNeUxTunPcDfKPjyJNrCNf2F1zQLcUv5UHAruECpX+zz21CzsC7V2xAeM7Q==", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.21.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
"integrity": "sha512-70S31vgpCGcp9J+mh/wHtLCkVezLUqe/fGWk3J3JWZIN7prdYSlr1C0niaWUyNK2VflLXYi8kMjAmSxUVq6WGQ==", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.21.1", "@prisma/debug": "5.22.0",
"@prisma/engines-version": "5.21.1-1.bf0e5e8a04cada8225617067eaa03d041e2bba36", "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
"@prisma/get-platform": "5.21.1" "@prisma/get-platform": "5.22.0"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.21.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
"integrity": "sha512-sRxjL3Igst3ct+e8ya/x//cDXmpLbZQ5vfps2N4tWl4VGKQAmym77C/IG/psSMsQKszc8uFC/q1dgmKFLUgXZQ==", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "5.21.1" "@prisma/debug": "5.22.0"
} }
}, },
"node_modules/@socket.io/component-emitter": { "node_modules/@socket.io/component-emitter": {
@ -596,24 +602,28 @@
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node12": { "node_modules/@tsconfig/node12": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node14": { "node_modules/@tsconfig/node14": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tsconfig/node16": { "node_modules/@tsconfig/node16": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/bcryptjs": { "node_modules/@types/bcryptjs": {
@ -719,18 +729,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.17.1", "version": "20.17.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz",
"integrity": "sha512-j2VlPv1NnwPJbaCNv69FO/1z4lId0QmGvpT41YxitRtWlg96g/j8qcv2RKsLKe2F6OJgyXhupN1Xo17b2m139Q==", "integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.19.2" "undici-types": "~6.19.2"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.17",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.9.16", "version": "6.9.17",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.16.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
"integrity": "sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==", "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -778,9 +798,10 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.13.0", "version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
"integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -793,6 +814,7 @@
"version": "8.3.4", "version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"acorn": "^8.11.0" "acorn": "^8.11.0"
@ -819,6 +841,7 @@
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/array-flatten": { "node_modules/array-flatten": {
@ -926,14 +949,14 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "5.21.2", "version": "5.34.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.21.2.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.34.3.tgz",
"integrity": "sha512-LPuNoGaDc5CON2X6h4cJ2iVfd+B+02xubFU+IB/fyJHd+/HqUZRqnlYryUCAuhVHBhUKtA6oyVdJxqSa62i+og==", "integrity": "sha512-S8/V11w7p6jYAGvv+00skLza/4inTOupWPe0uCD8mZSUiYKzvmW4/YEB+KVjZI2CC2oD3KJ3t7/KkUd31MxMig==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.6.0", "cron-parser": "^4.6.0",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"msgpackr": "^1.10.1", "msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1", "node-abort-controller": "^3.1.1",
"semver": "^7.5.4", "semver": "^7.5.4",
"tslib": "^2.0.0", "tslib": "^2.0.0",
@ -949,17 +972,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.7", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
"integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"function-bind": "^1.1.2", "function-bind": "^1.1.2"
"get-intrinsic": "^1.2.4", },
"set-function-length": "^1.2.1" "engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz",
"integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"get-intrinsic": "^1.2.6"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1103,6 +1136,7 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cron-parser": { "node_modules/cron-parser": {
@ -1126,23 +1160,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": { "node_modules/denque": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@ -1184,15 +1201,16 @@
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.3.1" "node": ">=0.3.1"
} }
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.5", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -1201,6 +1219,20 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": { "node_modules/ecdsa-sig-formatter": {
"version": "1.0.11", "version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -1288,13 +1320,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT", "license": "MIT",
"dependencies": {
"get-intrinsic": "^1.2.4"
},
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
} }
@ -1308,6 +1337,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-object-atoms": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
"integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -1324,9 +1365,9 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.1", "version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
@ -1348,7 +1389,7 @@
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.10", "path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "6.13.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
@ -1363,6 +1404,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10.0" "node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/fast-redact": { "node_modules/fast-redact": {
@ -1447,16 +1492,21 @@
} }
}, },
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.4", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
"integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"dunder-proto": "^1.0.0",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"function-bind": "^1.1.2", "function-bind": "^1.1.2",
"has-proto": "^1.0.1", "gopd": "^1.2.0",
"has-symbols": "^1.0.3", "has-symbols": "^1.1.0",
"hasown": "^2.0.0" "hasown": "^2.0.2",
"math-intrinsics": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1479,12 +1529,12 @@
} }
}, },
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.0.1", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "engines": {
"get-intrinsic": "^1.1.3" "node": ">= 0.4"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@ -1500,34 +1550,10 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-proto": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz",
"integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.0.3", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1590,9 +1616,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ioredis": { "node_modules/ioredis": {
"version": "5.4.1", "version": "5.4.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.2.tgz",
"integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", "integrity": "sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ioredis/commands": "^1.1.1", "@ioredis/commands": "^1.1.1",
@ -1614,9 +1640,9 @@
} }
}, },
"node_modules/ioredis/node_modules/debug": { "node_modules/ioredis/node_modules/debug": {
"version": "4.3.7", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -1813,8 +1839,18 @@
"version": "1.3.6", "version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": { "node_modules/media-typer": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -1895,9 +1931,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/msgpackr": { "node_modules/msgpackr": {
"version": "1.11.0", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz",
"integrity": "sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==", "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==",
"license": "MIT", "license": "MIT",
"optionalDependencies": { "optionalDependencies": {
"msgpackr-extract": "^3.0.2" "msgpackr-extract": "^3.0.2"
@ -1955,10 +1991,19 @@
"node-gyp-build-optional-packages-test": "build-test.js" "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": { "node_modules/nodemon": {
"version": "3.1.7", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
"integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -1985,9 +2030,9 @@
} }
}, },
"node_modules/nodemon/node_modules/debug": { "node_modules/nodemon/node_modules/debug": {
"version": "4.3.7", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2029,9 +2074,9 @@
} }
}, },
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.2", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
"integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -2071,9 +2116,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.10", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
@ -2127,9 +2172,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -2143,13 +2188,13 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "5.21.1", "version": "5.22.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.21.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
"integrity": "sha512-PB+Iqzld/uQBPaaw2UVIk84kb0ITsLajzsxzsadxxl54eaU5Gyl2/L02ysivHxK89t7YrfQJm+Ggk37uvM70oQ==", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/engines": "5.21.1" "@prisma/engines": "5.22.0"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"
@ -2376,23 +2421,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -2439,15 +2467,69 @@
} }
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.0.6", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.7",
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"get-intrinsic": "^1.2.4", "object-inspect": "^1.13.3",
"object-inspect": "^1.13.1" "side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -2679,6 +2761,7 @@
"version": "10.9.2", "version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
@ -2719,9 +2802,9 @@
} }
}, },
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.0", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
@ -2738,9 +2821,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.6.3", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -2798,6 +2881,7 @@
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vary": { "node_modules/vary": {
@ -2834,15 +2918,16 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.23.8", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"

View File

@ -15,11 +15,11 @@
"express": "^4.19.2", "express": "^4.19.2",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.15",
"pino": "^9.3.2", "pino": "^9.3.2",
"prisma": "^5.17.0", "prisma": "^5.17.0",
"sharp": "^0.33.4", "sharp": "^0.33.4",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -28,6 +28,8 @@
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/nodemailer": "^6.4.16",
"ts-node": "^10.9.2",
"nodemon": "^3.1.4", "nodemon": "^3.1.4",
"prettier": "^3.3.3" "prettier": "^3.3.3"
} }

View File

@ -1,10 +1,21 @@
-- 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 -- CreateTable
CREATE TABLE `Chat` ( CREATE TABLE `Chat` (
`id` INTEGER NOT NULL AUTO_INCREMENT, `id` INTEGER NOT NULL AUTO_INCREMENT,
`characterId` INTEGER NOT NULL, `characterId` INTEGER NOT NULL,
`zoneId` INTEGER NOT NULL, `zoneId` INTEGER NOT NULL,
`message` VARCHAR(191) NOT NULL, `message` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@ -36,14 +47,41 @@ CREATE TABLE `SpriteAction` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Item` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`itemType` ENUM('WEAPON', 'HELMET', 'CHEST', 'LEGS', 'BOOTS', 'GLOVES', 'RING', 'NECKLACE') NOT NULL,
`stackable` BOOLEAN NOT NULL DEFAULT false,
`rarity` ENUM('COMMON', 'UNCOMMON', 'RARE', 'EPIC', 'LEGENDARY') NOT NULL DEFAULT 'COMMON',
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `User` ( CREATE TABLE `User` (
`id` INTEGER NOT NULL AUTO_INCREMENT, `id` INTEGER NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL, `username` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL, `password` VARCHAR(191) NOT NULL,
`online` BOOLEAN NOT NULL DEFAULT false, `online` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `User_username_key`(`username`), 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`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@ -53,6 +91,7 @@ CREATE TABLE `CharacterType` (
`name` VARCHAR(191) NOT NULL, `name` VARCHAR(191) NOT NULL,
`gender` ENUM('MALE', 'FEMALE') NOT NULL, `gender` ENUM('MALE', 'FEMALE') NOT NULL,
`race` ENUM('HUMAN', 'ELF', 'DWARF', 'ORC', 'GOBLIN') NOT NULL, `race` ENUM('HUMAN', 'ELF', 'DWARF', 'ORC', 'GOBLIN') NOT NULL,
`isEnabledForCharCreation` BOOLEAN NOT NULL DEFAULT false,
`spriteId` VARCHAR(191) NULL, `spriteId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL, `updatedAt` DATETIME(3) NOT NULL,
@ -60,23 +99,39 @@ CREATE TABLE `CharacterType` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CharacterHair` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`gender` ENUM('MALE', 'FEMALE') NOT NULL DEFAULT 'MALE',
`isEnabledForCharCreation` BOOLEAN NOT NULL DEFAULT false,
`spriteId` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `Character` ( CREATE TABLE `Character` (
`id` INTEGER NOT NULL AUTO_INCREMENT, `id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL, `userId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL, `name` VARCHAR(191) NOT NULL,
`online` BOOLEAN NOT NULL DEFAULT false, `online` BOOLEAN NOT NULL DEFAULT false,
`role` VARCHAR(191) NOT NULL DEFAULT 'player',
`zoneId` INTEGER NOT NULL DEFAULT 1,
`positionX` INTEGER NOT NULL DEFAULT 0,
`positionY` INTEGER NOT NULL DEFAULT 0,
`rotation` INTEGER NOT NULL DEFAULT 0,
`characterTypeId` INTEGER NULL,
`characterHairId` INTEGER NULL,
`alignment` INTEGER NOT NULL DEFAULT 50,
`hitpoints` INTEGER NOT NULL DEFAULT 100, `hitpoints` INTEGER NOT NULL DEFAULT 100,
`mana` INTEGER NOT NULL DEFAULT 100, `mana` INTEGER NOT NULL DEFAULT 100,
`level` INTEGER NOT NULL DEFAULT 1, `level` INTEGER NOT NULL DEFAULT 1,
`experience` INTEGER NOT NULL DEFAULT 0, `experience` INTEGER NOT NULL DEFAULT 0,
`alignment` INTEGER NOT NULL DEFAULT 50, `strength` INTEGER NOT NULL DEFAULT 10,
`role` VARCHAR(191) NOT NULL DEFAULT 'player', `dexterity` INTEGER NOT NULL DEFAULT 10,
`positionX` INTEGER NOT NULL DEFAULT 0, `intelligence` INTEGER NOT NULL DEFAULT 10,
`positionY` INTEGER NOT NULL DEFAULT 0, `wisdom` INTEGER NOT NULL DEFAULT 10,
`rotation` INTEGER NOT NULL DEFAULT 0,
`zoneId` INTEGER NOT NULL DEFAULT 1,
`characterTypeId` INTEGER NULL,
UNIQUE INDEX `Character_name_key`(`name`), UNIQUE INDEX `Character_name_key`(`name`),
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
@ -92,6 +147,17 @@ CREATE TABLE `CharacterItem` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `CharacterEquipment` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`characterId` INTEGER NOT NULL,
`itemId` VARCHAR(191) NOT NULL,
`quantity` INTEGER NOT NULL,
`slot` ENUM('HEAD', 'BODY', 'ARMS', 'LEGS', 'NECK', 'RING') NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `Tile` ( CREATE TABLE `Tile` (
`id` VARCHAR(191) NOT NULL, `id` VARCHAR(191) NOT NULL,
@ -120,18 +186,6 @@ CREATE TABLE `Object` (
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `Item` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`description` VARCHAR(191) NULL,
`stackable` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `Zone` ( CREATE TABLE `Zone` (
`id` INTEGER NOT NULL AUTO_INCREMENT, `id` INTEGER NOT NULL AUTO_INCREMENT,
@ -202,9 +256,15 @@ ALTER TABLE `Chat` ADD CONSTRAINT `Chat_zoneId_fkey` FOREIGN KEY (`zoneId`) REFE
-- AddForeignKey -- AddForeignKey
ALTER TABLE `SpriteAction` ADD CONSTRAINT `SpriteAction_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 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 -- AddForeignKey
ALTER TABLE `CharacterType` ADD CONSTRAINT `CharacterType_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `CharacterType` ADD CONSTRAINT `CharacterType_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CharacterHair` ADD CONSTRAINT `CharacterHair_spriteId_fkey` FOREIGN KEY (`spriteId`) REFERENCES `Sprite`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `Character` ADD CONSTRAINT `Character_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `Character` ADD CONSTRAINT `Character_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
@ -214,12 +274,21 @@ ALTER TABLE `Character` ADD CONSTRAINT `Character_zoneId_fkey` FOREIGN KEY (`zon
-- AddForeignKey -- AddForeignKey
ALTER TABLE `Character` ADD CONSTRAINT `Character_characterTypeId_fkey` FOREIGN KEY (`characterTypeId`) REFERENCES `CharacterType`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `Character` ADD CONSTRAINT `Character_characterTypeId_fkey` FOREIGN KEY (`characterTypeId`) REFERENCES `CharacterType`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `Character` ADD CONSTRAINT `Character_characterHairId_fkey` FOREIGN KEY (`characterHairId`) REFERENCES `CharacterHair`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_characterId_fkey` FOREIGN KEY (`characterId`) REFERENCES `Character`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_characterId_fkey` FOREIGN KEY (`characterId`) REFERENCES `Character`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_itemId_fkey` FOREIGN KEY (`itemId`) REFERENCES `Item`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `CharacterItem` ADD CONSTRAINT `CharacterItem_itemId_fkey` FOREIGN KEY (`itemId`) REFERENCES `Item`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CharacterEquipment` ADD CONSTRAINT `CharacterEquipment_characterId_fkey` FOREIGN KEY (`characterId`) REFERENCES `Character`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `CharacterEquipment` ADD CONSTRAINT `CharacterEquipment_itemId_fkey` FOREIGN KEY (`itemId`) REFERENCES `Item`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `ZoneEffect` ADD CONSTRAINT `ZoneEffect_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `ZoneEffect` ADD CONSTRAINT `ZoneEffect_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

74
prisma/schema/game.prisma Normal file
View File

@ -0,0 +1,74 @@
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
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
spriteActions SpriteAction[]
characterTypes CharacterType[]
characterHairs CharacterHair[]
}
model SpriteAction {
id String @id @default(uuid())
spriteId String
sprite Sprite @relation(fields: [spriteId], references: [id], onDelete: Cascade)
action String
sprites Json?
originX Decimal @default(0)
originY Decimal @default(0)
isAnimated Boolean @default(false)
isLooping Boolean @default(false)
frameWidth Int @default(0)
frameHeight Int @default(0)
frameSpeed Int @default(0)
}
model Item {
id String @id @default(uuid())
name String
description String?
itemType ItemType
stackable Boolean @default(false)
rarity ItemRarity @default(COMMON)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
characters CharacterItem[]
equipment CharacterEquipment[]
}
enum ItemType {
WEAPON
HELMET
CHEST
LEGS
BOOTS
GLOVES
RING
NECKLACE
}
enum ItemRarity {
COMMON
UNCOMMON
RARE
EPIC
LEGENDARY
}

View File

@ -11,7 +11,7 @@
// npx prisma migrate deploy // npx prisma migrate deploy
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["prismaSchemaFolder"] previewFeatures = ["prismaSchemaFolder"]
} }
@ -19,13 +19,3 @@ datasource db {
provider = "mysql" provider = "mysql"
url = env("DATABASE_URL") 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,23 +0,0 @@
model Sprite {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
spriteActions SpriteAction[]
characterTypes CharacterType[]
}
model SpriteAction {
id String @id @default(uuid())
spriteId String
sprite Sprite @relation(fields: [spriteId], references: [id], onDelete: Cascade)
action String
sprites Json?
originX Decimal @default(0)
originY Decimal @default(0)
isAnimated Boolean @default(false)
isLooping Boolean @default(false)
frameWidth Int @default(0)
frameHeight Int @default(0)
frameSpeed Int @default(0)
}

View File

@ -1,11 +1,3 @@
model User {
id Int @id @default(autoincrement())
username String @unique
password String
online Boolean @default(false)
characters Character[]
}
enum CharacterGender { enum CharacterGender {
MALE MALE
FEMALE FEMALE
@ -19,39 +11,83 @@ enum CharacterRace {
GOBLIN GOBLIN
} }
model User {
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())
}
model CharacterType { model CharacterType {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
gender CharacterGender gender CharacterGender
race CharacterRace race CharacterRace
characters Character[] isEnabledForCharCreation Boolean @default(false)
spriteId String? characters Character[]
sprite Sprite? @relation(fields: [spriteId], references: [id], onDelete: Cascade) spriteId String?
createdAt DateTime @default(now()) sprite Sprite? @relation(fields: [spriteId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CharacterHair {
id Int @id @default(autoincrement())
name String
gender CharacterGender @default(MALE)
isEnabledForCharCreation Boolean @default(false)
sprite Sprite? @relation(fields: [spriteId], references: [id], onDelete: Cascade)
spriteId String?
characters Character[]
} }
model Character { model Character {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId Int userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String @unique name String @unique
online Boolean @default(false) online Boolean @default(false)
hitpoints Int @default(100) role String @default("player")
mana Int @default(100) chats Chat[]
level Int @default(1)
experience Int @default(0) // Position
alignment Int @default(50) zoneId Int @default(1)
role String @default("player") zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
positionX Int @default(0) positionX Int @default(0)
positionY Int @default(0) positionY Int @default(0)
rotation Int @default(0) rotation Int @default(0)
zoneId Int @default(1)
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade) // Customization
characterTypeId Int? characterTypeId Int?
characterType CharacterType? @relation(fields: [characterTypeId], references: [id], onDelete: Cascade) characterType CharacterType? @relation(fields: [characterTypeId], references: [id], onDelete: Cascade)
chats Chat[] characterHairId Int?
items CharacterItem[] characterHair CharacterHair? @relation(fields: [characterHairId], references: [id], onDelete: Cascade)
// Inventory
items CharacterItem[]
equipment CharacterEquipment[]
// Stats
alignment Int @default(50)
hitpoints Int @default(100)
mana Int @default(100)
level Int @default(1)
experience Int @default(0)
strength Int @default(10)
dexterity Int @default(10)
intelligence Int @default(10)
wisdom Int @default(10)
} }
model CharacterItem { model CharacterItem {
@ -62,3 +98,22 @@ model CharacterItem {
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
quantity Int quantity Int
} }
model CharacterEquipment {
id Int @id @default(autoincrement())
characterId Int
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
itemId String
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
quantity Int
slot CharacterEquipmentSlotType
}
enum CharacterEquipmentSlotType {
HEAD
BODY
ARMS
LEGS
NECK
RING
}

View File

@ -21,16 +21,6 @@ model Object {
ZoneObject ZoneObject[] ZoneObject ZoneObject[]
} }
model Item {
id String @id @default(uuid())
name String
description String?
stackable Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
characters CharacterItem[]
}
model Zone { model Zone {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String

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

@ -1,7 +1,7 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { appLogger } from '../utilities/logger' import { appLogger } from '../utilities/logger'
import { getRootPath } from '../utilities/storage' import worldService from '../services/worldService'
import { readJsonValue, setJsonValue } from '../utilities/json' import worldRepository from '../repositories/worldRepository'
class DateManager { class DateManager {
private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours
@ -18,17 +18,37 @@ class DateManager {
appLogger.info('Date manager loaded') appLogger.info('Date manager loaded')
} }
public stop(): void { public async setTime(time: string): Promise<void> {
if (this.intervalId) { try {
clearInterval(this.intervalId) let newDate: Date
this.intervalId = null
// 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> { private async loadDate(): Promise<void> {
try { try {
const dateString = await readJsonValue<string>(this.getWorldFilePath(), 'date') const world = await worldRepository.getFirst()
this.currentDate = new Date(dateString)
if (world) {
this.currentDate = world.date
}
} catch (error) { } catch (error) {
appLogger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`)
this.currentDate = new Date() // Use current date as fallback this.currentDate = new Date() // Use current date as fallback
@ -39,7 +59,7 @@ class DateManager {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.advanceGameTime() this.advanceGameTime()
this.emitDate() this.emitDate()
this.saveDate() void this.saveDate()
}, DateManager.UPDATE_INTERVAL) }, DateManager.UPDATE_INTERVAL)
} }
@ -54,14 +74,22 @@ class DateManager {
private async saveDate(): Promise<void> { private async saveDate(): Promise<void> {
try { try {
await setJsonValue(this.getWorldFilePath(), 'date', this.currentDate) await worldService.update({
date: this.currentDate
})
} catch (error) { } catch (error) {
appLogger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
private getWorldFilePath(): string { public cleanup(): void {
return getRootPath('data', 'world.json') if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public getCurrentDate(): Date {
return this.currentDate
} }
} }

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

@ -3,53 +3,53 @@ import ZoneRepository from '../repositories/zoneRepository'
import ZoneService from '../services/zoneService' import ZoneService from '../services/zoneService'
import LoadedZone from '../models/loadedZone' import LoadedZone from '../models/loadedZone'
import { gameLogger } from '../utilities/logger' import { gameLogger } from '../utilities/logger'
import ZoneCharacter from '../models/zoneCharacter'
class ZoneManager { class ZoneManager {
private loadedZones: LoadedZone[] = [] private readonly zones = new Map<number, LoadedZone>()
// Method to initialize zoneEditor manager public async boot(): Promise<void> {
public async boot() { // Create first zone if it doesn't exist
if (!(await ZoneRepository.getById(1))) { if (!(await ZoneRepository.getById(1))) {
const zoneService = new ZoneService() await new ZoneService().createDemoZone()
await zoneService.createDemoZone()
} }
const zones = await ZoneRepository.getAll() const zones = await ZoneRepository.getAll()
await Promise.all(zones.map((zone) => this.loadZone(zone)))
for (const zone of zones) { gameLogger.info(`Zone manager loaded with ${this.zones.size} zones`)
await this.loadZone(zone)
}
gameLogger.info('Zone manager loaded')
} }
// Method to handle individual zoneEditor loading public async loadZone(zone: Zone): Promise<void> {
public async loadZone(zone: Zone) {
const loadedZone = new LoadedZone(zone) const loadedZone = new LoadedZone(zone)
this.loadedZones.push(loadedZone) this.zones.set(zone.id, loadedZone)
gameLogger.info(`Zone ID ${zone.id} loaded`) gameLogger.info(`Zone ID ${zone.id} loaded`)
} }
// Method to handle individual zoneEditor unloading public unloadZone(zoneId: number): void {
public unloadZone(zoneId: number) { this.zones.delete(zoneId)
this.loadedZones = this.loadedZones.filter((loadedZone) => loadedZone.getZone().id !== zoneId)
gameLogger.info(`Zone ID ${zoneId} unloaded`) gameLogger.info(`Zone ID ${zoneId} unloaded`)
} }
// Getter for loaded zones
public getLoadedZones(): LoadedZone[] { public getLoadedZones(): LoadedZone[] {
return this.loadedZones return Array.from(this.zones.values())
} }
// Getter for zone by id
public getZoneById(zoneId: number): LoadedZone | undefined { public getZoneById(zoneId: number): LoadedZone | undefined {
return this.loadedZones.find((loadedZone) => loadedZone.getZone().id === zoneId) return this.zones.get(zoneId)
} }
}
export interface ZoneAssets { public getCharacter(characterId: number): ZoneCharacter | undefined {
tiles: string[] for (const zone of this.zones.values()) {
objects: string[] 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() export default new ZoneManager()

View File

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

View File

@ -1,9 +1,10 @@
import { Zone } from '@prisma/client' import { Character, Zone } from '@prisma/client'
import zoneRepository from '../repositories/zoneRepository' import zoneEventTileRepository from '../repositories/zoneEventTileRepository'
import ZoneCharacter from './zoneCharacter'
class LoadedZone { class LoadedZone {
private readonly zone: Zone private readonly zone: Zone
// private readonly npcs: ZoneNPC[] = [] private characters: ZoneCharacter[] = []
constructor(zone: Zone) { constructor(zone: Zone) {
this.zone = zone this.zone = zone
@ -13,10 +14,31 @@ class LoadedZone {
return this.zone 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[][]> { public async getGrid(): Promise<number[][]> {
let grid: number[][] = Array.from({ length: this.zone.height }, () => Array.from({ length: this.zone.width }, () => 0)) 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 // Set the grid values based on the event tiles, these are strings
eventTiles.forEach((eventTile) => { eventTiles.forEach((eventTile) => {
@ -27,20 +49,6 @@ class LoadedZone {
return grid 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 export default LoadedZone

View File

@ -0,0 +1,19 @@
import { Character } from '@prisma/client'
import { CharacterService } from '../services/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

@ -0,0 +1,17 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance
import { CharacterHair } from '@prisma/client'
class CharacterHairRepository {
async getAll(): Promise<CharacterHair[]> {
return prisma.characterHair.findMany()
}
async getIsEnabledForCharCreationHair(): Promise<CharacterHair[]> {
return prisma.characterHair.findMany({
where: {
isEnabledForCharCreation: true
}
})
}
}
export default new CharacterHairRepository()

View File

@ -1,8 +1,8 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance import prisma from '../utilities/prisma' // Import the global Prisma instance
import { Character } from '@prisma/client' import { appLogger } from '../utilities/logger'
class CharacterRepository { class CharacterRepository {
async getByUserId(userId: number): Promise<Character[] | null> { async getByUserId(userId: number) {
try { try {
return await prisma.character.findMany({ return await prisma.character.findMany({
where: { where: {
@ -14,16 +14,22 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // 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
} }
} }
async getByUserAndId(userId: number, characterId: number): Promise<Character | null> { async getByUserAndId(userId: number, characterId: number) {
try { try {
return await prisma.character.findFirst({ return await prisma.character.findFirst({
where: { where: {
@ -36,16 +42,22 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // 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
} }
} }
async getById(id: number): Promise<Character | null> { async getById(id: number) {
try { try {
return await prisma.character.findUnique({ return await prisma.character.findUnique({
where: { where: {
@ -57,47 +69,22 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // 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
} }
} }
async updatePosition(id: number, positionX: number, positionY: number): Promise<Character | null> { async getByName(name: string) {
try {
return await prisma.character.update({
where: {
id: id
},
data: {
positionX,
positionY
}
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to update character: ${error.message}`)
}
}
async deleteByUserIdAndId(userId: number, characterId: number): Promise<Character | null> {
try {
return await prisma.character.delete({
where: {
userId,
id: characterId
}
})
} catch (error: any) {
// Handle error
throw new Error(`Failed to delete character by user ID and character ID: ${error.message}`)
}
}
async getByName(name: string): Promise<Character | null> {
try { try {
return await prisma.character.findFirst({ return await prisma.character.findFirst({
where: { where: {
@ -109,12 +96,18 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // 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,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,8 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance import prisma from '../utilities/prisma' // Import the global Prisma instance
import { Tile } from '@prisma/client' import { Tile } from '@prisma/client'
import zoneRepository from './zoneRepository'
import { unduplicateArray } from '../utilities/utilities'
import { FlattenZoneArray } from '../utilities/zone'
class TileRepository { class TileRepository {
async getById(id: string): Promise<Tile | null> { 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[]> { async getAll(): Promise<Tile[]> {
return prisma.tile.findMany() 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() export default new TileRepository()

View File

@ -1,5 +1,6 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance import prisma from '../utilities/prisma' // Import the global Prisma instance
import { User } from '@prisma/client' import { User } from '@prisma/client'
import { appLogger } from '../utilities/logger'
class UserRepository { class UserRepository {
async getById(id: number): Promise<User | null> { async getById(id: number): Promise<User | null> {
@ -11,7 +12,8 @@ class UserRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // 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) { } catch (error: any) {
// Handle error // 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,36 @@
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,20 +1,9 @@
import { Zone, ZoneEventTile, ZoneEventTileType, ZoneObject } from '@prisma/client' import { Zone, ZoneEventTile, ZoneEventTileType, ZoneObject } from '@prisma/client'
import prisma from '../utilities/prisma' import prisma from '../utilities/prisma'
import { ZoneEventTileWithTeleport } from '../socketEvents/zone/characterMove' import { ZoneEventTileWithTeleport } from '../utilities/types'
import { appLogger } from '../utilities/logger' import { appLogger } from '../utilities/logger'
import { TAsset } from '../utilities/types'
import tileRepository from './tileRepository'
class ZoneRepository { 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[]> { async getAll(): Promise<Zone[]> {
try { try {
return await prisma.zone.findMany() return await prisma.zone.findMany()
@ -64,18 +53,19 @@ class ZoneRepository {
} }
} }
async getEventTeleportTiles(id: number): Promise<ZoneEventTileWithTeleport[]> { async getFirstEventTile(zoneId: number, positionX: number, positionY: number): Promise<ZoneEventTile | null> {
try { try {
return (await prisma.zoneEventTile.findMany({ return await prisma.zoneEventTile.findFirst({
where: { where: {
zoneId: id, zoneId: zoneId,
type: ZoneEventTileType.TELEPORT positionX: positionX,
positionY: positionY
}, },
include: { teleport: true } include: { teleport: true }
})) as unknown as ZoneEventTileWithTeleport[] })
} catch (error: any) { } catch (error: any) {
appLogger.error(`Failed to get zone event tiles: ${error.message}`) appLogger.error(`Failed to get zone event tile: ${error.message}`)
return [] return null
} }
} }
@ -91,52 +81,6 @@ class ZoneRepository {
return [] return []
} }
} }
async getZoneAssets(zone_id: number): Promise<TAsset[]> {
const zone = await this.getById(zone_id)
if (!zone) return []
const assets: TAsset[] = []
// zone.tiles is prisma jsonvalue
let tiles = JSON.parse(JSON.stringify(zone.tiles))
tiles = [...new Set(tiles.flat())]
// Add tile assets
for (const tile of tiles) {
const tileInfo = await tileRepository.getById(tile)
if (!tileInfo) continue
assets.push({
key: tileInfo.id,
url: '/assets/tiles/' + tileInfo.id + '.png',
group: 'tiles',
updatedAt: tileInfo?.updatedAt || new Date()
})
}
// Add object assets
for (const zoneObject of zone.zoneObjects) {
if (!zoneObject.object) continue
assets.push({
key: zoneObject.object.id,
url: '/assets/objects/' + zoneObject.object.id + '.png',
group: 'objects',
updatedAt: zoneObject.object.updatedAt || new Date()
})
}
// Filter out duplicate assets
return assets.reduce((acc: TAsset[], current) => {
const x = acc.find((item) => item.key === current.key && item.group === current.group)
if (!x) {
return acc.concat([current])
} else {
return acc
}
}, [])
}
} }
export default new ZoneRepository() export default new ZoneRepository()

View File

@ -13,9 +13,9 @@ import { appLogger, watchLogs } from './utilities/logger'
import ZoneManager from './managers/zoneManager' import ZoneManager from './managers/zoneManager'
import UserManager from './managers/userManager' import UserManager from './managers/userManager'
import CommandManager from './managers/commandManager' import CommandManager from './managers/commandManager'
import CharacterManager from './managers/characterManager'
import QueueManager from './managers/queueManager' import QueueManager from './managers/queueManager'
import DateManager from './managers/dateManager' import DateManager from './managers/dateManager'
import WeatherManager from './managers/weatherManager'
export class Server { export class Server {
private readonly app: Application private readonly app: Application
@ -27,7 +27,14 @@ export class Server {
*/ */
constructor() { constructor() {
this.app = express() 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.json())
this.app.use(express.urlencoded({ extended: true })) this.app.use(express.urlencoded({ extended: true }))
this.http = httpServer(this.app) this.http = httpServer(this.app)
@ -70,12 +77,12 @@ export class Server {
// Load date manager // Load date manager
await DateManager.boot(this.io) await DateManager.boot(this.io)
// Load weather manager
await WeatherManager.boot(this.io)
// Load zoneEditor manager // Load zoneEditor manager
await ZoneManager.boot() await ZoneManager.boot()
// Load character manager
await CharacterManager.boot()
// Load command manager // Load command manager
await CommandManager.boot(this.io) await CommandManager.boot(this.io)

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

@ -0,0 +1,104 @@
import { AStar } from '../utilities/character/aStar'
import ZoneManager from '../managers/zoneManager'
import prisma from '../utilities/prisma'
import Rotation from '../utilities/character/rotation'
import { appLogger, gameLogger } from '../utilities/logger'
import { Character } from '@prisma/client'
interface Position {
x: number
y: number
}
export class CharacterService {
private readonly MOVEMENT_DELAY_MS = 250
async create(name: string, userId: number) {
return prisma.character.create({
data: {
name,
userId
// characterTypeId: 1 // @TODO set to chosen character type
}
})
}
async updateHair(characterId: number, characterHairId: number | null) {
await prisma.character.update({
where: { id: characterId },
data: {
characterHairId
}
})
}
async deleteByUserIdAndId(userId: number, characterId: number): Promise<Character | null> {
try {
return await prisma.character.delete({
where: {
userId,
id: characterId
}
})
} catch (error: any) {
// Handle error
appLogger.error(`Failed to delete character by user ID and character ID: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
async updateCharacterPosition(id: number, positionX: number, positionY: number, rotation: number, zoneId: number) {
await prisma.character.update({
where: { id },
data: {
positionX,
positionY,
rotation,
zoneId
}
})
}
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, this.MOVEMENT_DELAY_MS))
}
private isValidPosition(position: Position): boolean {
return Number.isFinite(position.x) && Number.isFinite(position.y) && position.x >= 0 && position.y >= 0
}
}

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 bcrypt from 'bcryptjs'
import UserRepository from '../repositories/userRepository' import UserRepository from '../repositories/userRepository'
import PasswordResetTokenRepository from '../repositories/passwordResetTokenRepository'
import prisma from '../utilities/prisma' import prisma from '../utilities/prisma'
import { User } from '@prisma/client' 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 * User service
@ -15,37 +20,145 @@ class UserService {
* @param password * @param password
*/ */
async login(username: string, password: string): Promise<boolean | User> { async login(username: string, password: string): Promise<boolean | User> {
const user = await UserRepository.getByUsername(username) try {
if (!user) { 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 return false
} }
const passwordMatch = await bcrypt.compare(password, user.password)
if (!passwordMatch) {
return false
}
return user
} }
/** /**
* Register user * Register user
* @param username * @param username
* @param email
* @param password * @param password
*/ */
async register(username: string, password: string): Promise<boolean | User> { async register(username: string, email: string, password: string): Promise<boolean | User> {
const user = await UserRepository.getByUsername(username) try {
if (user) { 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 return false
} }
}
const hashedPassword = await bcrypt.hash(password, 10) /**
return prisma.user.create({ * Reset password
data: { * @param email
username, */
password: hashedPassword 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 { ExtendedCharacter, TSocket } from '../utilities/types'
import prisma from '../utilities/prisma' import prisma from '../utilities/prisma'
import ZoneRepository from '../repositories/zoneRepository'
import { ZoneEventTileTeleport } from '@prisma/client' import { ZoneEventTileTeleport } from '@prisma/client'
import { Server } from 'socket.io' import { Server } from 'socket.io'
import CharacterManager from '../managers/characterManager' import ZoneManager from '../managers/zoneManager'
import { gameLogger } from '../utilities/logger'
export class ZoneEventTileService { export class ZoneEventTileService {
public async handleTeleport(io: Server, socket: TSocket, character: ExtendedCharacter, teleport: ZoneEventTileTeleport): Promise<void> { public async handleTeleport(io: Server, socket: TSocket, character: ExtendedCharacter, teleport: ZoneEventTileTeleport): Promise<void> {
if (teleport.toZoneId === character.zoneId) return if (teleport.toZoneId === character.zoneId) return
const zone = await ZoneRepository.getById(teleport.toZoneId) const loadedZone = ZoneManager.getZoneById(teleport.toZoneId)
if (!zone) return 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 oldZoneId = character.zoneId
const newZoneId = teleport.toZoneId const newZoneId = teleport.toZoneId
@ -35,6 +38,10 @@ export class ZoneEventTileService {
character.positionY = teleport.toPositionY character.positionY = teleport.toPositionY
character.isMoving = false character.isMoving = false
// Remove and add character to new zone
await loadedZone.removeCharacter(character.id)
loadedZone.addCharacter(character)
// Emit events // Emit events
io.to(oldZoneId.toString()).emit('zone:character:leave', character.id) io.to(oldZoneId.toString()).emit('zone:character:leave', character.id)
io.to(newZoneId.toString()).emit('zone:character:join', character) io.to(newZoneId.toString()).emit('zone:character:join', character)
@ -46,7 +53,7 @@ export class ZoneEventTileService {
// Send teleport information to the client // Send teleport information to the client
socket.emit('zone:character:teleport', { socket.emit('zone:character:teleport', {
zone, zone,
characters: CharacterManager.getCharactersInZone(zone) characters: loadedZone.getCharactersInZone()
}) })
} }
} }

View File

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

View File

@ -0,0 +1,22 @@
import { Server } from 'socket.io'
import { CharacterHair } from '@prisma/client'
import { TSocket } from '../../../utilities/types'
import characterHairRepository from '../../../repositories/characterHairRepository'
interface IPayload {}
export default class characterHairListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:hair:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: CharacterHair[]) => void): Promise<void> {
const items = await characterHairRepository.getIsEnabledForCharCreationHair()
callback(items)
}
}

View File

@ -1,9 +1,13 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types' import { TSocket } from '../../utilities/types'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import { gameLogger } from '../../utilities/logger'
import ZoneManager from '../../managers/zoneManager'
import { CharacterService } from '../../services/characterService'
type SocketResponseT = { interface CharacterConnectPayload {
character_id: number characterId: number
characterHairId?: number
} }
export default class CharacterConnectEvent { export default class CharacterConnectEvent {
@ -16,16 +20,48 @@ export default class CharacterConnectEvent {
this.socket.on('character:connect', this.handleCharacterConnect.bind(this)) this.socket.on('character:connect', this.handleCharacterConnect.bind(this))
} }
private async handleCharacterConnect(data: SocketResponseT): Promise<void> { private async handleCharacterConnect({ characterId, characterHairId }: CharacterConnectPayload): Promise<void> {
console.log('character:connect requested', data) if (!this.socket.userId) {
this.emitError('User not authenticated')
return
}
try { try {
const character = await CharacterRepository.getByUserAndId(this.socket?.user?.id as number, data.character_id) if (await this.hasActiveCharacter()) {
if (!character) return this.emitError('You are already connected to another character')
return
}
// Update hair
const characterService = new CharacterService()
await characterService.updateHair(characterId, characterHairId ?? null)
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, characterId)
if (!character) {
this.emitError('Character not found or does not belong to this user')
return
}
this.socket.characterId = character.id this.socket.characterId = character.id
this.socket.emit('character:connect', character) this.socket.emit('character:connect', character)
} catch (error: any) { } catch (error) {
console.log('character:connect error', 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 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,8 +2,8 @@ import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types' import { TSocket } from '../../utilities/types'
import { Character } from '@prisma/client' import { Character } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import { CharacterService } from '../../services/characterService'
import { ZCharacterCreate } from '../../utilities/zodTypes' import { ZCharacterCreate } from '../../utilities/zodTypes'
import prisma from '../../utilities/prisma'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import { ZodError } from 'zod' import { ZodError } from 'zod'
@ -23,7 +23,7 @@ export default class CharacterCreateEvent {
try { try {
data = ZCharacterCreate.parse(data) data = ZCharacterCreate.parse(data)
const user_id = this.socket.user?.id as number const user_id = this.socket.userId!
// Check if character name already exists // Check if character name already exists
const characterExists = await CharacterRepository.getByName(data.name) const characterExists = await CharacterRepository.getByName(data.name)
@ -38,13 +38,8 @@ export default class CharacterCreateEvent {
return this.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({ const characterService = new CharacterService()
data: { const character: Character = await characterService.create(data.name, user_id)
name: data.name,
userId: user_id
// characterTypeId: 1 // @TODO set to chosen character type
}
})
characters = [...characters, character] characters = [...characters, character]

View File

@ -2,9 +2,10 @@ import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types' import { TSocket } from '../../utilities/types'
import { Character, Zone } from '@prisma/client' import { Character, Zone } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import { CharacterService } from '../../services/characterService'
type TypePayload = { type TypePayload = {
character_id: number characterId: number
} }
type TypeResponse = { type TypeResponse = {
@ -23,16 +24,14 @@ export default class CharacterDeleteEvent {
} }
private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> { private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> {
// zod validate
try { try {
await CharacterRepository.deleteByUserIdAndId(this.socket.user?.id as number, data.character_id as number) const characterService = new CharacterService()
await characterService.deleteByUserIdAndId(this.socket.userId!, data.characterId!)
const user_id = this.socket.user?.id as number const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[]
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
this.socket.emit('character:list', characters) this.socket.emit('character:list', characters)
} catch (error: any) { } catch (error: any) {
console.log(error)
return this.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,6 +2,7 @@ import { Socket, Server } from 'socket.io'
import { TSocket } from '../../utilities/types' import { TSocket } from '../../utilities/types'
import { Character } from '@prisma/client' import { Character } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import { gameLogger } from '../../utilities/logger'
export default class CharacterListEvent { export default class CharacterListEvent {
constructor( constructor(
@ -15,12 +16,10 @@ export default class CharacterListEvent {
private async handleCharacterList(data: any): Promise<void> { private async handleCharacterList(data: any): Promise<void> {
try { try {
console.log('character:list requested') const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[]
const user_id = this.socket.user?.id as number
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
this.socket.emit('character:list', characters) this.socket.emit('character:list', characters)
} catch (error: any) { } 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 { 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> { private async handleAlertCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
@ -24,22 +24,27 @@ export default class AlertCommandEvent {
return 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) const args = getArgs('alert', data.message)
if (!args) { if (!args) {
callback(false) return 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
} }
this.io.emit('notification', { title: 'Message from GM', message: args.join(' ') }) this.io.emit('notification', { title: 'Message from GM', message: args.join(' ') })
callback(true) return callback(true)
} catch (error: any) { } catch (error: any) {
gameLogger.error('chat:alert_command error', error.message) gameLogger.error('chat:alert_command error', error.message)
callback(false) 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,10 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../utilities/types' import { TSocket } from '../../../utilities/types'
import { getArgs, isCommand } from '../../../utilities/chat' import { getArgs, isCommand } from '../../../utilities/chat'
import ZoneRepository from '../../../repositories/zoneRepository' import ZoneRepository from '../../../repositories/zoneRepository'
import CharacterManager from '../../../managers/characterManager' import { gameLogger, gameMasterLogger } from '../../../utilities/logger'
import { gameMasterLogger } from '../../../utilities/logger' import ZoneManager from '../../../managers/zoneManager'
import ZoneCharacter from '../../../models/zoneCharacter'
import zoneManager from '../../../managers/zoneManager'
type TypePayload = { type TypePayload = {
message: string message: string
@ -16,14 +18,23 @@ export default class TeleportCommandEvent {
) {} ) {}
public listen(): void { 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> { private async handleTeleportCommand(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
try { try {
const character = CharacterManager.getCharacterFromSocket(this.socket) // Check if character exists
if (!character) { const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
this.socket.emit('notification', { title: 'Server message', message: 'Character not found' }) 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 return
} }
@ -54,10 +65,12 @@ export default class TeleportCommandEvent {
} }
// Remove character from current zone // Remove character from current zone
zoneManager.removeCharacter(character.id)
this.io.to(character.zoneId.toString()).emit('zone:character:leave', character.id) this.io.to(character.zoneId.toString()).emit('zone:character:leave', character.id)
this.socket.leave(character.zoneId.toString()) this.socket.leave(character.zoneId.toString())
// Add character to new zone // Add character to new zone
zoneManager.getZoneById(zone.id)?.addCharacter(character)
this.io.to(zone.id.toString()).emit('zone:character:join', character) this.io.to(zone.id.toString()).emit('zone:character:join', character)
this.socket.join(zone.id.toString()) this.socket.join(zone.id.toString())
@ -65,17 +78,15 @@ export default class TeleportCommandEvent {
character.positionX = 0 character.positionX = 0
character.positionY = 0 character.positionY = 0
character.resetMovement = true zoneCharacter.isMoving = false
this.socket.emit('zone:character:teleport', { this.socket.emit('zone:character:teleport', {
zone, 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}` }) 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}`) gameMasterLogger.info('teleport', `Character ${character.id} teleported to zone ${zone.id}`)
callback(true)
} catch (error: any) { } catch (error: any) {
gameMasterLogger.error(`Error in teleport command: ${error.message}`) gameMasterLogger.error(`Error in teleport command: ${error.message}`)
this.socket.emit('notification', { title: 'Server message', message: 'An error occurred while teleporting' }) 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 ZoneRepository from '../../repositories/zoneRepository'
import { isCommand } from '../../utilities/chat'
import { gameLogger } from '../../utilities/logger'
import CharacterManager from '../../managers/characterManager'
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 = CharacterManager.getCharacterFromSocket(this.socket)
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 { Server } from 'socket.io'
import { TSocket } from '../utilities/types' import { TSocket } from '../utilities/types'
import CharacterManager from '../managers/characterManager'
import { gameLogger } from '../utilities/logger' import { gameLogger } from '../utilities/logger'
import ZoneManager from '../managers/zoneManager'
export default class DisconnectEvent { export default class DisconnectEvent {
constructor( constructor(
@ -10,31 +10,34 @@ export default class DisconnectEvent {
) {} ) {}
public listen(): void { 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 { try {
if (!this.socket.user) { if (!this.socket.userId) {
gameLogger.info('User disconnected but had no user set') gameLogger.info('User disconnected but had no user set')
return return
} }
this.io.emit('user:disconnect', this.socket.user.id) this.io.emit('user:disconnect', this.socket.userId)
const character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
if (!character) {
gameLogger.info('User disconnected but had no character set') gameLogger.info('User disconnected but had no character set')
return return
} }
character.resetMovement = true 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') 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.in(character.zoneId.toString()).emit('zone:character:leave', character.id)
this.io.emit('character:disconnect', character.id) this.io.emit('character:disconnect', character.id)
} catch (error: any) { } catch (error: any) {

View File

@ -0,0 +1,37 @@
import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository'
export default class CharacterHairCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterHair: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 newCharacterHair = await prisma.characterHair.create({
data: {
name: 'New hair'
}
})
callback(true, newCharacterHair)
} catch (error) {
console.error('Error creating character hair:', error)
callback(false)
}
}
}

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 { gameMasterLogger } from '../../../../utilities/logger'
interface IPayload {
id: number
}
export default class characterHairDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterHair: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.characterHair.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 { CharacterHair } from '@prisma/client'
import characterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
import characterHairRepository from '../../../../repositories/characterHairRepository'
interface IPayload {}
export default class characterHairListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterHair:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: CharacterHair[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:characterHair:list error', 'Character not found')
return callback([])
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to list character hair but is not a game master.`)
return callback([])
}
// get all objects
const items = await characterHairRepository.getAll()
callback(items)
}
}

View File

@ -0,0 +1,51 @@
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'
import { gameMasterLogger } from '../../../../utilities/logger'
type Payload = {
id: number
name: string
gender: CharacterGender
isEnabledForCharCreation: boolean
spriteId: string
}
export default class CharacterHairUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:characterHair: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.characterHair.update({
where: { id: data.id },
data: {
name: data.name,
gender: data.gender,
isEnabledForCharCreation: data.isEnabledForCharCreation,
spriteId: data.spriteId
}
})
return callback(true)
} catch (error) {
gameMasterLogger.error(`Error updating character hair: ${error instanceof Error ? error.message : String(error)}`)
return callback(false)
}
}
}

View File

@ -11,10 +11,10 @@ export default class CharacterTypeCreateEvent {
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('gm:characterType:create', this.handleCharacterTypeCreate.bind(this)) this.socket.on('gm:characterType:create', this.handleEvent.bind(this))
} }
private async handleCharacterTypeCreate(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> { private async handleEvent(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> {
try { try {
const character = await characterRepository.getById(this.socket.characterId as number) const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false) if (!character) return callback(false)

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 { 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

@ -14,10 +14,10 @@ export default class CharacterTypeListEvent {
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('gm:characterType:list', this.handleCharacterTypeList.bind(this)) this.socket.on('gm:characterType:list', this.handleEvent.bind(this))
} }
private async handleCharacterTypeList(data: IPayload, callback: (response: CharacterType[]) => void): Promise<void> { private async handleEvent(data: IPayload, callback: (response: CharacterType[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number) const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) { if (!character) {
gameMasterLogger.error('gm:characterType:list error', 'Character not found') gameMasterLogger.error('gm:characterType:list error', 'Character not found')

View File

@ -1,56 +0,0 @@
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'
interface IPayload {
object: string
}
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') {
return callback(false)
}
try {
await prisma.object.delete({
where: {
id: data.object
}
})
// get root path
const public_folder = getPublicPath('objects')
// remove the tile from the disk
const finalFilePath = getPublicPath('objects', data.object + '.png')
fs.unlink(finalFilePath, (err) => {
if (err) {
console.log(err)
callback(false)
return
}
callback(true)
})
} catch (e) {
console.log(e)
callback(false)
}
}
}

View File

@ -2,27 +2,25 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types' import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma' import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository' import characterRepository from '../../../../repositories/characterRepository'
import { CharacterGender, CharacterRace } from '@prisma/client'
type Payload = { type Payload = {
id: string id: number
name: string name: string
tags: string[] gender: CharacterGender
originX: number race: CharacterRace
originY: number isEnabledForCharCreation: boolean
isAnimated: boolean spriteId: string
frameSpeed: number
frameWidth: number
frameHeight: number
} }
export default class ObjectUpdateEvent { export default class CharacterTypeUpdateEvent {
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private readonly socket: TSocket private readonly socket: TSocket
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('gm:object:update', this.handleObjectUpdate.bind(this)) this.socket.on('gm:characterType:update', this.handleObjectUpdate.bind(this))
} }
private async handleObjectUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> { private async handleObjectUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
@ -34,21 +32,17 @@ export default class ObjectUpdateEvent {
} }
try { try {
const object = await prisma.object.update({ await prisma.characterType.update({
where: { where: { id: data.id },
id: data.id
},
data: { data: {
name: data.name, name: data.name,
tags: data.tags, gender: data.gender,
originX: data.originX, race: data.race,
originY: data.originY, isEnabledForCharCreation: data.isEnabledForCharCreation,
isAnimated: data.isAnimated, spriteId: data.spriteId
frameSpeed: data.frameSpeed,
frameWidth: data.frameWidth,
frameHeight: data.frameHeight
} }
}) })
callback(true) callback(true)
} catch (error) { } catch (error) {
console.error(error) console.error(error)

View File

@ -4,6 +4,7 @@ import { TSocket } from '../../../../utilities/types'
import prisma from '../../../../utilities/prisma' import prisma from '../../../../utilities/prisma'
import characterRepository from '../../../../repositories/characterRepository' import characterRepository from '../../../../repositories/characterRepository'
import { getPublicPath } from '../../../../utilities/storage' import { getPublicPath } from '../../../../utilities/storage'
import { gameLogger, gameMasterLogger } from '../../../../utilities/logger'
interface IPayload { interface IPayload {
object: string object: string
@ -41,15 +42,15 @@ export default class ObjectRemoveEvent {
const finalFilePath = getPublicPath('objects', data.object + '.png') const finalFilePath = getPublicPath('objects', data.object + '.png')
fs.unlink(finalFilePath, (err) => { fs.unlink(finalFilePath, (err) => {
if (err) { if (err) {
console.log(err) gameMasterLogger.error(`Error deleting object ${data.object}: ${err.message}`)
callback(false) callback(false)
return return
} }
callback(true) callback(true)
}) })
} catch (e) { } catch (error) {
console.log(e) gameLogger.error(`Error deleting object ${data.object}: ${error instanceof Error ? error.message : String(error)}`)
callback(false) callback(false)
} }
} }

View File

@ -17,7 +17,7 @@ export default class SpriteCreateEvent {
private async handleSpriteCreate(data: undefined, callback: (response: boolean) => void): Promise<void> { private async handleSpriteCreate(data: undefined, callback: (response: boolean) => void): Promise<void> {
try { try {
const character = await characterRepository.getById(this.socket.characterId as number) const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback(false) if (!character) return callback(false)
if (character.role !== 'gm') { if (character.role !== 'gm') {

View File

@ -2,9 +2,9 @@ import { Server } from 'socket.io'
import { TSocket } from '../../../../utilities/types' import { TSocket } from '../../../../utilities/types'
import fs from 'fs' import fs from 'fs'
import prisma from '../../../../utilities/prisma' import prisma from '../../../../utilities/prisma'
import CharacterManager from '../../../../managers/characterManager'
import { gameMasterLogger } from '../../../../utilities/logger' import { gameMasterLogger } from '../../../../utilities/logger'
import { getPublicPath } from '../../../../utilities/storage' import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
type Payload = { type Payload = {
id: string id: string
@ -21,11 +21,11 @@ export default class GMSpriteDeleteEvent {
} }
public listen(): void { 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> { private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket) const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') { if (character?.role !== 'gm') {
return callback(false) return callback(false)
} }

View File

@ -17,7 +17,7 @@ export default class SpriteListEvent {
} }
private async handleSpriteList(data: any, callback: (response: Sprite[]) => void): Promise<void> { private async handleSpriteList(data: any, callback: (response: Sprite[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number) const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback([]) if (!character) return callback([])
if (character.role !== 'gm') { if (character.role !== 'gm') {

View File

@ -4,30 +4,92 @@ import prisma from '../../../../utilities/prisma'
import type { Prisma, SpriteAction } from '@prisma/client' import type { Prisma, SpriteAction } from '@prisma/client'
import { writeFile, mkdir } from 'node:fs/promises' import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp' import sharp from 'sharp'
import CharacterManager from '../../../../managers/characterManager'
import { getPublicPath } from '../../../../utilities/storage' import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & { interface ContentBounds {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
}
interface IsometricSettings {
tileWidth: number;
tileHeight: number;
centerOffset: number;
bodyRatios: {
topStart: number; // Where to start analyzing upper body (%)
topEnd: number; // Where to end analyzing upper body (%)
weightUpper: number; // Weight given to upper body centering
weightLower: number; // Weight given to lower body centering
};
}
// Types
interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> {
sprites: string[] sprites: string[]
} }
type Payload = { interface Payload {
id: string id: string
name: string name: string
spriteActions: Prisma.JsonValue spriteActions: Prisma.JsonValue
} }
interface IsometricGrid {
tileWidth: number; // Standard isometric tile width (typically 64px)
tileHeight: number; // Standard isometric tile height (typically 32px)
centerOffset: number; // Center offset for proper tile alignment
}
interface ProcessedSpriteAction extends SpriteActionInput { interface ProcessedSpriteAction extends SpriteActionInput {
frameWidth: number frameWidth: number
frameHeight: number frameHeight: number
buffersWithDimensions: Array<{ buffersWithDimensions: ProcessedBuffer[]
buffer: Buffer }
width: number | undefined
height: number | undefined interface ProcessedBuffer {
}> buffer: Buffer
width: number
height: number
}
interface SpriteDimensions {
width: number
height: number
baselineY: number
contentHeight: number
}
interface IsometricCenter {
centerX: number
verticalCenterLine: number
} }
export default class SpriteUpdateEvent { export default class SpriteUpdateEvent {
private readonly ISOMETRIC = {
tileWidth: 64,
tileHeight: 32,
centerOffset: 32,
bodyRatios: {
topStart: 0.15, // Start at 15% from top
topEnd: 0.45, // End at 45% from top
weightUpper: 0.7, // 70% weight to upper body
weightLower: 0.3 // 30% weight to lower body
}
} as const;
private readonly ISOMETRIC_SETTINGS: IsometricGrid = {
tileWidth: 64, // Habbo-style standard tile width
tileHeight: 32, // Habbo-style standard tile height
centerOffset: 32 // Center point of the tile
};
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private readonly socket: TSocket private readonly socket: TSocket
@ -37,119 +99,323 @@ export default class SpriteUpdateEvent {
this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this)) this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this))
} }
private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> { private async handleSpriteUpdate(
const character = CharacterManager.getCharacterFromSocket(this.socket) data: Payload,
if (character?.role !== 'gm') { callback: (success: boolean) => void
return callback(false) ): Promise<void> {
}
try { try {
const parsedSpriteActions = validateSpriteActions(data.spriteActions) const character = await CharacterRepository.getById(this.socket.characterId!)
const processedActions = await processSprites(parsedSpriteActions) if (character?.role !== 'gm') {
return callback(false)
}
await updateDatabase(data.id, data.name, processedActions) const parsedActions = this.validateSpriteActions(data.spriteActions)
await saveSpritesToDisk(data.id, processedActions) const processedActions = await this.processSprites(parsedActions)
await this.updateDatabase(data.id, data.name, processedActions)
await this.saveSpritesToDisk(data.id, processedActions)
callback(true) callback(true)
} catch (error) { } catch (error) {
console.error('Error updating sprite:', error) gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false) callback(false)
} }
}
function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] { private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
try { try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[] const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error('spriteActions is not an array') throw new Error('spriteActions is not an array')
}
return parsed
} catch (error) {
throw new Error(`Invalid sprite actions format: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async processSprites(actions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
return Promise.all(actions.map(async (action) => {
const spriteBuffers = await this.convertSpritesToBuffers(action.sprites);
// Analyze first frame to get reference values
const frameWidth = this.ISOMETRIC.tileWidth;
const frameHeight = await this.calculateOptimalHeight(spriteBuffers);
// Process all frames using reference center from first frame
const processedBuffers = await Promise.all(
spriteBuffers.map(async (buffer) => {
const normalized = await this.normalizeIsometricSprite(
buffer,
frameWidth,
frameHeight
);
return {
buffer: normalized,
width: frameWidth,
height: frameHeight
};
})
);
return {
...action,
frameWidth,
frameHeight,
buffersWithDimensions: processedBuffers
};
}));
}
private async calculateOptimalHeight(buffers: Buffer[]): Promise<number> {
const heights = await Promise.all(
buffers.map(async (buffer) => {
const bounds = await this.findContentBounds(buffer);
return bounds.height;
})
);
// Ensure height is even for perfect pixel alignment
return Math.ceil(Math.max(...heights) / 2) * 2;
}
private async convertSpritesToBuffers(sprites: string[]): Promise<Buffer[]> {
return Promise.all(
sprites.map(sprite => {
const base64Data = sprite.split(',')[1]
return Buffer.from(base64Data, 'base64')
})
)
}
private calculateMassCenter(density: number[]): number {
let totalMass = 0;
let weightedSum = 0;
density.forEach((mass, position) => {
totalMass += mass;
weightedSum += position * mass;
});
return totalMass ? Math.round(weightedSum / totalMass) : 0;
}
private async normalizeIsometricSprite(
buffer: Buffer,
frameWidth: number,
frameHeight: number,
): Promise<Buffer> {
const analysis = await this.analyzeIsometricSprite(buffer);
// Calculate optimal position
const idealCenter = Math.floor(frameWidth / 2);
const offset = idealCenter - analysis.massCenter;
// Ensure pixel-perfect alignment
const adjustedOffset = Math.round(offset);
// Create perfectly centered frame
return sharp({
create: {
width: frameWidth,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite([{
input: buffer,
left: adjustedOffset,
top: 0,
}])
.png()
.toBuffer();
}
private async findContentBounds(buffer: Buffer) {
const { data, info } = await sharp(buffer)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const width = info.width;
const height = info.height;
let left = width;
let right = 0;
let top = height;
let bottom = 0;
// Find actual content boundaries
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] > 0) { // If pixel is not transparent
left = Math.min(left, x);
right = Math.max(right, x);
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
}
return {
width: right - left + 1,
height: bottom - top + 1,
leftOffset: left,
topOffset: top
};
}
private async analyzeIsometricSprite(buffer: Buffer): Promise<{
massCenter: number;
spinePosition: number;
contentBounds: ContentBounds;
}> {
const { data, info } = await sharp(buffer)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const width = info.width;
const height = info.height;
// Separate analysis for upper and lower body
const upperStart = Math.floor(height * this.ISOMETRIC.bodyRatios.topStart);
const upperEnd = Math.floor(height * this.ISOMETRIC.bodyRatios.topEnd);
const columnDensity = new Array(width).fill(0);
const upperBodyDensity = new Array(width).fill(0);
let leftmost = width;
let rightmost = 0;
let topmost = height;
let bottommost = 0;
// Analyze pixel distribution
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] > 0) {
columnDensity[x]++;
if (y >= upperStart && y <= upperEnd) {
upperBodyDensity[x]++;
}
leftmost = Math.min(leftmost, x);
rightmost = Math.max(rightmost, x);
topmost = Math.min(topmost, y);
bottommost = Math.max(bottommost, y);
} }
return parsed
} catch (error) {
console.error('Error parsing spriteActions:', error)
throw error
} }
} }
async function processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> { // Find spine (densest vertical line in upper body)
return Promise.all( let maxDensity = 0;
spriteActions.map(async (spriteAction) => { let spinePosition = 0;
const { action, sprites } = spriteAction for (let x = 0; x < width; x++) {
if (upperBodyDensity[x] > maxDensity) {
if (!Array.isArray(sprites) || sprites.length === 0) { maxDensity = upperBodyDensity[x];
throw new Error(`Invalid sprites array for action: ${action}`) spinePosition = x;
} }
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
}
})
)
} }
async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) { // Calculate weighted mass center
await prisma.sprite.update({ const upperMassCenter = this.calculateMassCenter(upperBodyDensity);
where: { id }, const lowerMassCenter = this.calculateMassCenter(columnDensity);
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 massCenter = Math.round(
const publicFolder = getPublicPath('sprites', id) upperMassCenter * this.ISOMETRIC.bodyRatios.weightUpper +
await mkdir(publicFolder, { recursive: true }) lowerMassCenter * this.ISOMETRIC.bodyRatios.weightLower
);
await Promise.all( return {
processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => { massCenter,
const combinedImage = await sharp({ spinePosition,
create: { contentBounds: {
width: frameWidth * buffersWithDimensions.length, left: leftmost,
height: frameHeight, right: rightmost,
channels: 4, top: topmost,
background: { r: 0, g: 0, b: 0, alpha: 0 } bottom: bottommost,
} width: rightmost - leftmost + 1,
}) height: bottommost - topmost + 1
.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)
})
)
}
} }
}
private async saveSpritesToDisk(
id: string,
processedActions: ProcessedSpriteAction[]
): Promise<void> {
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
processedActions.map(async (action) => {
const spritesheet = await this.createSpritesheet(
action.buffersWithDimensions,
action.frameWidth,
action.frameHeight
)
const filename = getPublicPath('sprites', id, `${action.action}.png`)
await writeFile(filename, spritesheet)
})
)
}
private async createSpritesheet(
frames: ProcessedBuffer[],
frameWidth: number,
frameHeight: number
): Promise<Buffer> {
// Create background with precise isometric tile width
const background = await sharp({
create: {
width: this.ISOMETRIC_SETTINGS.tileWidth * frames.length,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
}).png().toBuffer();
// Composite frames with exact tile-based positioning
return sharp(background)
.composite(
frames.map((frame, index) => ({
input: frame.buffer,
left: index * this.ISOMETRIC_SETTINGS.tileWidth,
top: 0
}))
)
.png()
.toBuffer();
}
private async updateDatabase(
id: string,
name: string,
processedActions: ProcessedSpriteAction[]
): Promise<void> {
await prisma.sprite.update({
where: { id },
data: {
name,
spriteActions: {
deleteMany: { spriteId: id },
create: processedActions.map(action => ({
action: action.action,
sprites: action.sprites,
originX: action.originX,
originY: action.originY,
isAnimated: action.isAnimated,
isLooping: action.isLooping,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight,
frameSpeed: action.frameSpeed
}))
}
}
})
}
}

View File

@ -39,40 +39,52 @@ export default class ZoneUpdateEvent {
) {} ) {}
public listen(): void { 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 { try {
const character = await CharacterRepository.getById(this.socket.characterId as number) const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) { if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:update error', 'Character not found') gameMasterLogger.error('gm:zone_editor:zone:update error', 'Character not found')
callback(null) return callback(null)
return
} }
if (character.role !== 'gm') { if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to update zone but is not a game master.`) gameMasterLogger.info(`User ${character.id} tried to update zone but is not a game master.`)
callback(null) return callback(null)
return
} }
gameMasterLogger.info(`User ${character.id} has updated zone via zone editor.`) gameMasterLogger.info(`User ${character.id} has updated zone via zone editor.`)
if (!data.zoneId) { if (!data.zoneId) {
gameMasterLogger.info(`User ${character.id} tried to update zone but did not provide a zone id.`) gameMasterLogger.info(`User ${character.id} tried to update zone but did not provide a zone id.`)
callback(null) return callback(null)
return
} }
let zone = await ZoneRepository.getById(data.zoneId) let zone = await ZoneRepository.getById(data.zoneId)
if (!zone) { if (!zone) {
gameMasterLogger.info(`User ${character.id} tried to update zone ${data.zoneId} but it does not exist.`) gameMasterLogger.info(`User ${character.id} tried to update zone ${data.zoneId} but it does not exist.`)
callback(null) return callback(null)
return
} }
// 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({ await prisma.zone.update({
where: { id: data.zoneId }, where: { id: data.zoneId },
data: { data: {
@ -134,10 +146,13 @@ export default class ZoneUpdateEvent {
callback(zone) callback(zone)
/**
* @TODO #246: Reload zone for players who are currently in the zone
*/
zoneManager.unloadZone(data.zoneId) zoneManager.unloadZone(data.zoneId)
await zoneManager.loadZone(zone) await zoneManager.loadZone(zone)
} catch (error: any) { } 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) callback(null)
} }
} }

View File

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

View File

@ -1,14 +1,16 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { ExtendedCharacter, TSocket } from '../../utilities/types' import { TSocket } from '../../utilities/types'
import ZoneRepository from '../../repositories/zoneRepository' import ZoneRepository from '../../repositories/zoneRepository'
import { Character, Zone } from '@prisma/client' import { Zone } from '@prisma/client'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import ZoneManager from '../../managers/zoneManager'
import zoneCharacter from '../../models/zoneCharacter'
import zoneManager from '../../managers/zoneManager'
interface IResponse { interface IResponse {
zone: Zone zone: Zone
characters: Character[] characters: zoneCharacter[]
} }
export default class CharacterJoinEvent { export default class CharacterJoinEvent {
@ -28,30 +30,42 @@ export default class CharacterJoinEvent {
return return
} }
const character = await CharacterRepository.getById(this.socket.characterId as number) const character = await CharacterRepository.getById(this.socket.characterId)
if (!character) { if (!character) {
gameLogger.error('zone:character:join error', 'Character not found') gameLogger.error('zone:character:join error', 'Character not found')
return return
} }
/**
* @TODO: If zone is not found, spawn back to the start
*/
const zone = await ZoneRepository.getById(character.zoneId) const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) { if (!zone) {
gameLogger.error('zone:character:join error', 'Zone not found') gameLogger.error('zone:character:join error', 'Zone not found')
return return
} }
CharacterManager.initCharacter(character as ExtendedCharacter) /**
* @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()) this.socket.join(zone.id.toString())
// let other clients know of new character // Let other clients know of new character
this.io.to(zone.id.toString()).emit('zone:character:join', character) this.io.to(zone.id.toString()).emit('zone:character:join', zoneManager.getCharacter(character.id))
// Log // Log
gameLogger.info(`User ${character.id} joined zone ${zone.id}`) gameLogger.info(`User ${character.id} joined zone ${zone.id}`)
// send over zone and characters to socket // Send over zone and characters to socket
callback({ zone, characters: CharacterManager.getCharactersInZone(zone) }) callback({ zone, characters: loadedZone.getCharactersInZone() })
} catch (error: any) { } catch (error: any) {
gameLogger.error('zone:character:join error', error.message) gameLogger.error('zone:character:join error', error.message)
this.socket.disconnect() this.socket.disconnect()

View File

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

View File

@ -1,144 +1,96 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { TSocket, ExtendedCharacter } from '../../utilities/types' import { TSocket, ZoneEventTileWithTeleport } from '../../utilities/types'
import { CharacterMoveService } from '../../services/character/characterMoveService' import { CharacterService } from '../../services/characterService'
import { ZoneEventTileService } from '../../services/zoneEventTileService' import { ZoneEventTileService } from '../../services/zoneEventTileService'
import prisma from '../../utilities/prisma'
import { ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client'
import Rotation from '../../utilities/character/rotation' import Rotation from '../../utilities/character/rotation'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import QueueManager from '../../managers/queueManager' import ZoneManager from '../../managers/zoneManager'
import ZoneCharacter from '../../models/zoneCharacter'
export type ZoneEventTileWithTeleport = ZoneEventTile & { import zoneEventTileRepository from '../../repositories/zoneEventTileRepository'
teleport: ZoneEventTileTeleport
}
export default class CharacterMove { export default class CharacterMove {
private characterMoveService: CharacterMoveService private readonly characterService = new CharacterService()
private zoneEventTileService: ZoneEventTileService private readonly zoneEventTileService = new ZoneEventTileService()
private nextPath: { [index: number]: { x: number; y: number }[] } = []
private currentZoneId: { [index: number]: number } = []
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private readonly socket: TSocket private readonly socket: TSocket
) { ) {}
this.characterMoveService = new CharacterMoveService()
this.zoneEventTileService = new ZoneEventTileService()
}
public listen(): void { 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> { private async handleCharacterMove({ positionX, positionY }: { positionX: number; positionY: number }): Promise<void> {
let character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter?.character) {
gameLogger.error('character:move error', 'Character not found') gameLogger.error('character:move error', 'Character not found or not initialized')
return return
} }
if (!character) { // If already moving, cancel current movement and wait for it to fully stop
gameLogger.error('character:move error', 'character has not been initialized?') if (zoneCharacter.isMoving) {
return 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) { 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 return
} }
if (!character.isMoving && character.resetMovement) { // Start new movement
character.resetMovement = false zoneCharacter.isMoving = true
} zoneCharacter.currentPath = path // Add this property to ZoneCharacter class
if (character.isMoving && !character.resetMovement) { await this.moveAlongPath(zoneCharacter, path)
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)
}
} }
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++) { for (let i = 0; i < path.length - 1; i++) {
const start = path[i] // Exit if movement was cancelled or interrupted
const end = path[i + 1] if (!zoneCharacter.isMoving || zoneCharacter.currentPath !== path) {
return
}
// if (!(await this.movementValidator.isValidMove(character, end))) { const [start, end] = [path[i], path[i + 1]]
// break 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 break
} }
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y) this.characterService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', zoneCharacter)
const zoneEventTile = await prisma.zoneEventTile.findFirst({ await this.characterService.applyMovementDelay()
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()
} }
if (CharacterManager.hasResetMovement(character)) { // Only finalize if this path wasn't interrupted
character.resetMovement = false if (zoneCharacter.isMoving && zoneCharacter.currentPath === path) {
if (this.currentZoneId[character.id] === character.zoneId) { this.finalizeMovement(zoneCharacter)
await this.moveAlongPath(character, this.nextPath[character.id])
} else {
delete this.currentZoneId[character.id]
character.isMoving = false
}
} else {
this.finalizeMovement(character)
} }
} }
private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> { private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter) {
gameLogger.error('character:move error', 'Character not found') gameLogger.error('character:move error', 'Character not found')
return return
} }
const teleport = zoneEventTile.teleport if (zoneEventTile.teleport) {
if (teleport) { await this.zoneEventTileService.handleTeleport(this.io, this.socket, zoneCharacter.character, zoneEventTile.teleport)
await this.zoneEventTileService.handleTeleport(this.io, this.socket, character, teleport)
return
} }
} }
private finalizeMovement(character: ExtendedCharacter): void { private finalizeMovement(zoneCharacter: ZoneCharacter): void {
character.isMoving = false zoneCharacter.isMoving = false
this.io.in(character.zoneId.toString()).emit('character:move', character) 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 { class Rotation {
static calculate(X1: number, Y1: number, X2: number, Y2: number): number { static calculate(X1: number, Y1: number, X2: number, Y2: number): number {
let rotation = 0
if (config.ALLOW_DIAGONAL_MOVEMENT) { if (config.ALLOW_DIAGONAL_MOVEMENT) {
// Check diagonal movements
if (X1 > X2 && Y1 > Y2) { if (X1 > X2 && Y1 > Y2) {
rotation = 7 return 7
} else if (X1 < X2 && Y1 < Y2) { } else if (X1 < X2 && Y1 < Y2) {
rotation = 3 return 3
} else if (X1 > X2 && Y1 < Y2) { } else if (X1 > X2 && Y1 < Y2) {
rotation = 5 return 5
} else if (X1 < X2 && Y1 > Y2) { } else if (X1 < X2 && Y1 > Y2) {
rotation = 1 return 1
} }
} }
if (rotation === 0) { // Non-diagonal movements
if (X1 > X2) { if (X1 > X2) {
rotation = 6 return 6
} else if (X1 < X2) { } else if (X1 < X2) {
rotation = 2 return 2
} else if (Y1 < Y2) { } else if (Y1 < Y2) {
rotation = 4 return 4
} else if (Y1 > Y2) { } else if (Y1 > Y2) {
rotation = 0 return 0
}
} }
return rotation return 0 // Default case
} }
} }

View File

@ -1,6 +1,6 @@
export function isCommand(message: string, command?: string) { export function isCommand(message: string, command?: string) {
if (command) { if (command) {
return message.startsWith(`/${command} `) return message === `/${command}` || message.startsWith(`/${command} `)
} }
return message.startsWith('/') return message.startsWith('/')
} }

View File

@ -7,6 +7,7 @@ class config {
static REDIS_URL: string = process.env.REDIS_URL || 'redis://@127.0.0.1:6379/4' 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 HOST: string = process.env.HOST || '0.0.0.0'
static PORT: number = process.env.PORT ? parseInt(process.env.PORT) : 6969 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 JWT_SECRET: string = process.env.JWT_SECRET || 'secret'
static ALLOW_DIAGONAL_MOVEMENT: boolean = process.env.ALLOW_DIAGONAL_MOVEMENT === 'true' 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_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_X: number = parseInt(process.env.DEFAULT_CHARACTER_POS_X || '0')
static DEFAULT_CHARACTER_Y: number = parseInt(process.env.DEFAULT_CHARACTER_POS_Y || '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 export default config

View File

@ -2,11 +2,14 @@ import { Application, Request, Response } from 'express'
import UserService from '../services/userService' import UserService from '../services/userService'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import config from './config' import config from './config'
import { loginAccountSchema, registerAccountSchema } from './zodTypes' import { loginAccountSchema, registerAccountSchema, resetPasswordSchema, newPasswordSchema } from './zodTypes'
import fs from 'fs' import fs from 'fs'
import { httpLogger } from './logger' import { httpLogger } from './logger'
import { getPublicPath } from './storage' import { getPublicPath } from './storage'
import zoneRepository from '../repositories/zoneRepository' import TileRepository from '../repositories/tileRepository'
import { AssetData } from './types'
import ZoneRepository from '../repositories/zoneRepository'
import SpriteRepository from '../repositories/spriteRepository'
async function addHttpRoutes(app: Application) { async function addHttpRoutes(app: Application) {
/** /**
@ -40,16 +43,16 @@ async function addHttpRoutes(app: Application) {
* @param res * @param res
*/ */
app.post('/register', async (req: Request, res: Response) => { app.post('/register', async (req: Request, res: Response) => {
const { username, password } = req.body const { username, email, password } = req.body
try { try {
registerAccountSchema.parse({ username, password }) registerAccountSchema.parse({ username, email, password })
} catch (error: any) { } catch (error: any) {
return res.status(400).json({ message: error.errors[0]?.message }) return res.status(400).json({ message: error.errors[0]?.message })
} }
const userService = new UserService() const userService = new UserService()
const user = await userService.register(username, password) const user = await userService.register(username, email, password)
if (user) { if (user) {
return res.status(200).json({ message: 'User registered' }) return res.status(200).json({ message: 'User registered' })
@ -58,12 +61,82 @@ async function addHttpRoutes(app: Application) {
return res.status(400).json({ message: 'Failed to register user' }) 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 * Get all tiles from a zone as an array of ids
* @param req * @param req
* @param res * @param res
*/ */
app.get('/assets/tiles/:zoneId', async (req: Request, res: Response) => { 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 const zoneId = req.params.zoneId
// Check if zoneId is valid number // Check if zoneId is valid number
@ -72,28 +145,59 @@ async function addHttpRoutes(app: Application) {
} }
// Get zone by id // Get zone by id
const zone = await zoneRepository.getById(parseInt(zoneId)) const zone = await ZoneRepository.getById(parseInt(zoneId))
if (!zone) { if (!zone) {
return res.status(404).json({ message: 'Zone not found' }) return res.status(404).json({ message: 'Zone not found' })
} }
let tiles = zone.tiles; // Get all tiles
let assets: AssetData[] = []
// Convert to array const tiles = await TileRepository.getByZoneId(parseInt(zoneId))
tiles = JSON.parse(JSON.stringify(tiles)) as string[] for (const tile of tiles) {
assets.push({
// Flatten the array key: tile.id,
tiles = [...new Set(tiles.flat())] data: '/assets/tiles/' + tile.id + '.png',
group: 'tiles',
// Remove duplicates updatedAt: tile.updatedAt
tiles = tiles.filter((value, index, self) => self.indexOf(value) === index); } as AssetData)
}
// Return the array // Return the array
res.json(tiles) 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)
}) })
/** /**
* Get a specific asset * Download asset file
* @param req * @param req
* @param res * @param res
*/ */

View File

@ -1,49 +0,0 @@
import * as fs from 'fs/promises'
import { appLogger } from './logger'
export async function readJsonFile<T>(filePath: string): Promise<T> {
try {
const fileContent = await fs.readFile(filePath, 'utf-8')
return JSON.parse(fileContent) as T
} catch (error) {
appLogger.error(`Error reading JSON file: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
export async function writeJsonFile<T>(filePath: string, data: T): Promise<void> {
try {
const jsonString = JSON.stringify(data, null, 2)
await fs.writeFile(filePath, jsonString, 'utf-8')
} catch (error) {
appLogger.error(`Error writing JSON file: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
export async function readJsonValue<T>(filePath: string, paramPath: string): Promise<T> {
try {
const jsonContent = await readJsonFile<any>(filePath)
const paramValue = paramPath.split('.').reduce((obj, key) => obj && obj[key], jsonContent)
if (paramValue === undefined) {
throw new Error(`Parameter ${paramPath} not found in the JSON file`)
}
return paramValue as T
} catch (error) {
appLogger.error(`Error reading JSON parameter: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}
export async function setJsonValue<T>(filePath: string, key: string, value: any): Promise<void> {
try {
const data = await readJsonFile<T>(filePath)
const updatedData = { ...data, [key]: value }
await writeJsonFile(filePath, updatedData)
} catch (error) {
appLogger.error(`Error setting JSON value: ${error instanceof Error ? error.message : String(error)}`)
throw error
}
}

View File

@ -32,13 +32,31 @@ const watchLogs = () => {
LOG_TYPES.forEach((type) => { LOG_TYPES.forEach((type) => {
const logFile = getRootPath('logs', `${type}.log`) const logFile = getRootPath('logs', `${type}.log`)
fs.watchFile(logFile, (curr, prev) => { // Get initial file size
if (curr.size > prev.size) { const stats = fs.statSync(logFile)
const stream = fs.createReadStream(logFile, { start: prev.size, end: curr.size }) let lastPosition = stats.size
stream.on('data', (chunk) => {
console.log(`[${type}]\n${chunk.toString()}`) 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
}
})
}) })
}) })
} }

View File

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

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' }) .min(3, { message: 'Name must be at least 3 characters long' })
.max(255, { message: 'Name must be at most 255 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' }), .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 password: z
.string() .string()
.min(8, { .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
}