1
0
forked from noxious/server

Compare commits

..

1 Commits

Author SHA1 Message Date
4b1f6884ae Example code 2024-09-22 15:34:35 +02:00
100 changed files with 1517 additions and 3481 deletions

View File

@ -4,7 +4,6 @@ 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
@ -12,10 +11,4 @@ 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=""

5
.gitignore vendored
View File

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

View File

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

628
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"scripts": { "scripts": {
"start": "npx prisma migrate deploy && node dist/server.js", "start": "npx prisma migrate deploy && node dist/server.js",
"dev": "nodemon --ignore 'data/*' --exec ts-node src/server.ts", "dev": "nodemon --exec ts-node src/server.ts",
"build": "tsc", "build": "tsc",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
@ -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,8 +28,6 @@
"@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"
} }

0
prisma/.gitignore vendored
View File

View File

@ -1,21 +1,10 @@
-- 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 DEFAULT CURRENT_TIMESTAMP(3), `createdAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@ -47,41 +36,14 @@ 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;
@ -91,47 +53,30 @@ 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) NOT 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,
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,
`strength` INTEGER NOT NULL DEFAULT 10, `alignment` INTEGER NOT NULL DEFAULT 50,
`dexterity` INTEGER NOT NULL DEFAULT 10, `role` VARCHAR(191) NOT NULL DEFAULT 'player',
`intelligence` INTEGER NOT NULL DEFAULT 10, `positionX` INTEGER NOT NULL DEFAULT 0,
`wisdom` INTEGER NOT NULL DEFAULT 10, `positionY` INTEGER NOT NULL DEFAULT 0,
`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`)
@ -147,17 +92,6 @@ 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,
@ -186,6 +120,18 @@ 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,
@ -200,23 +146,12 @@ CREATE TABLE `Zone` (
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 `ZoneEffect` (
`id` VARCHAR(191) NOT NULL,
`zoneId` INTEGER NOT NULL,
`effect` VARCHAR(191) NOT NULL,
`strength` INTEGER NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `ZoneObject` ( CREATE TABLE `ZoneObject` (
`id` VARCHAR(191) NOT NULL, `id` VARCHAR(191) NOT NULL,
`zoneId` INTEGER NOT NULL, `zoneId` INTEGER NOT NULL,
`objectId` VARCHAR(191) NOT NULL, `objectId` VARCHAR(191) NOT NULL,
`depth` INTEGER NOT NULL DEFAULT 0, `depth` INTEGER NOT NULL DEFAULT 0,
`isRotated` BOOLEAN NOT NULL DEFAULT false,
`positionX` INTEGER NOT NULL DEFAULT 0, `positionX` INTEGER NOT NULL DEFAULT 0,
`positionY` INTEGER NOT NULL DEFAULT 0, `positionY` INTEGER NOT NULL DEFAULT 0,
@ -239,7 +174,6 @@ CREATE TABLE `ZoneEventTileTeleport` (
`id` VARCHAR(191) NOT NULL, `id` VARCHAR(191) NOT NULL,
`zoneEventTileId` VARCHAR(191) NOT NULL, `zoneEventTileId` VARCHAR(191) NOT NULL,
`toZoneId` INTEGER NOT NULL, `toZoneId` INTEGER NOT NULL,
`toRotation` INTEGER NOT NULL,
`toPositionX` INTEGER NOT NULL, `toPositionX` INTEGER NOT NULL,
`toPositionY` INTEGER NOT NULL, `toPositionY` INTEGER NOT NULL,
@ -256,15 +190,9 @@ 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;
@ -274,24 +202,12 @@ 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
ALTER TABLE `ZoneEffect` ADD CONSTRAINT `ZoneEffect_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey -- AddForeignKey
ALTER TABLE `ZoneObject` ADD CONSTRAINT `ZoneObject_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE `ZoneObject` ADD CONSTRAINT `ZoneObject_zoneId_fkey` FOREIGN KEY (`zoneId`) REFERENCES `Zone`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -1,74 +0,0 @@
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,3 +19,13 @@ 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

@ -0,0 +1,23 @@
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,3 +1,11 @@
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
@ -11,83 +19,39 @@ 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
isEnabledForCharCreation Boolean @default(false) characters Character[]
characters Character[] spriteId String
spriteId String? sprite Sprite @relation(fields: [spriteId], references: [id], onDelete: Cascade)
sprite Sprite? @relation(fields: [spriteId], references: [id], onDelete: Cascade) createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
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)
role String @default("player") hitpoints Int @default(100)
chats Chat[] mana Int @default(100)
level Int @default(1)
// Position experience Int @default(0)
zoneId Int @default(1) alignment Int @default(50)
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade) role String @default("player")
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)
// Customization zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
characterTypeId Int? characterTypeId Int?
characterType CharacterType? @relation(fields: [characterTypeId], references: [id], onDelete: Cascade) characterType CharacterType? @relation(fields: [characterTypeId], references: [id], onDelete: Cascade)
characterHairId Int? chats Chat[]
characterHair CharacterHair? @relation(fields: [characterHairId], references: [id], onDelete: Cascade) items CharacterItem[]
// 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 {
@ -98,22 +62,3 @@ 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,6 +21,16 @@ 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
@ -28,7 +38,6 @@ model Zone {
height Int @default(10) height Int @default(10)
tiles Json? tiles Json?
pvp Boolean @default(false) pvp Boolean @default(false)
zoneEffects ZoneEffect[]
zoneEventTiles ZoneEventTile[] zoneEventTiles ZoneEventTile[]
zoneEventTileTeleports ZoneEventTileTeleport[] zoneEventTileTeleports ZoneEventTileTeleport[]
zoneObjects ZoneObject[] zoneObjects ZoneObject[]
@ -38,24 +47,15 @@ model Zone {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model ZoneEffect {
id String @id @default(uuid())
zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
effect String
strength Int
}
model ZoneObject { model ZoneObject {
id String @id @default(uuid()) id String @id @default(uuid())
zoneId Int zoneId Int
zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade) zone Zone @relation(fields: [zoneId], references: [id], onDelete: Cascade)
objectId String objectId String
object Object @relation(fields: [objectId], references: [id], onDelete: Cascade) object Object @relation(fields: [objectId], references: [id], onDelete: Cascade)
depth Int @default(0) depth Int @default(0)
isRotated Boolean @default(false) positionX Int @default(0)
positionX Int @default(0) positionY Int @default(0)
positionY Int @default(0)
} }
enum ZoneEventTileType { enum ZoneEventTileType {
@ -81,7 +81,6 @@ model ZoneEventTileTeleport {
zoneEventTile ZoneEventTile @relation(fields: [zoneEventTileId], references: [id], onDelete: Cascade) zoneEventTile ZoneEventTile @relation(fields: [zoneEventTileId], references: [id], onDelete: Cascade)
toZoneId Int toZoneId Int
toZone Zone @relation(fields: [toZoneId], references: [id], onDelete: Cascade) toZone Zone @relation(fields: [toZoneId], references: [id], onDelete: Cascade)
toRotation Int
toPositionX Int toPositionX Int
toPositionY Int toPositionY Int
} }

View File

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

View File

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

View File

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

View File

@ -1,9 +1,14 @@
import { TSocket } from '../utilities/types' import { TSocket } from '../utilities/types'
import { Server as SocketServer } from 'socket.io' import { Server as SocketServer } from 'socket.io'
import QueueManager from '../managers/queueManager'
export default class SomeJob { export default class SomeJob {
constructor(private params: any) {} constructor(private params: any) {}
public static initializeSocket(socket: TSocket) {
socket.on('character:connect', () => QueueManager.newJob('SomeJob', {}, socket));
}
async execute(io: SocketServer, socket?: TSocket) { async execute(io: SocketServer, socket?: TSocket) {
// Handle the event // Handle the event
if (socket) { if (socket) {

View File

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

View File

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

View File

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

View File

@ -5,16 +5,19 @@ import { Server as SocketServer } from 'socket.io'
import { TSocket } from '../utilities/types' import { TSocket } from '../utilities/types'
import { queueLogger } from '../utilities/logger' import { queueLogger } from '../utilities/logger'
import fs from 'fs' import fs from 'fs'
import { getAppPath } from '../utilities/storage' import path from 'path'
import { Dirent } from 'node:fs'
class QueueManager { class QueueManager {
private connection!: IORedis private connection!: IORedis
private queue!: Queue private queue!: Queue
private worker!: Worker private worker!: Worker
private io!: SocketServer private io!: SocketServer
private socket!: TSocket
public async boot(io: SocketServer) { public async boot(io: SocketServer, socket: TSocket) {
this.io = io this.io = io
this.socket = socket
this.connection = new IORedis(config.REDIS_URL, { this.connection = new IORedis(config.REDIS_URL, {
maxRetriesPerRequest: null maxRetriesPerRequest: null
@ -46,15 +49,29 @@ class QueueManager {
}) })
queueLogger.info('Queue manager loaded') queueLogger.info('Queue manager loaded')
await this.initializeSocketJobs()
}
private async initializeSocketJobs() {
const dir = path.join(__dirname, '../jobs')
const files: Dirent[] = await fs.promises.readdir(dir, { withFileTypes: true })
for (const file of files) {
const fullPath = path.join(dir, file.name)
const module = await import(fullPath)
module.default.initializeSocket(this.socket);
}
} }
private async processJob(job: Job) { private async processJob(job: Job) {
const { jobName, params, socketId } = job.data const { jobName, params, socketId } = job.data
try { try {
const jobsDir = getAppPath('jobs') const jobsDir = path.join(process.cwd(), 'src', 'jobs')
const extension = config.ENV === 'development' ? '.ts' : '.js' const extension = config.ENV === 'development' ? '.ts' : '.js'
const jobPath = getAppPath('jobs', `${jobName}${extension}`) const jobPath = path.join(jobsDir, `${jobName}${extension}`)
if (!fs.existsSync(jobPath)) { if (!fs.existsSync(jobPath)) {
queueLogger.warn(`Job file not found: ${jobPath}`) queueLogger.warn(`Job file not found: ${jobPath}`)

View File

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

View File

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

View File

@ -1,10 +1,11 @@
import { Character, Zone } from '@prisma/client' import { Zone } from '@prisma/client'
import zoneEventTileRepository from '../repositories/zoneEventTileRepository' import zoneRepository from '../repositories/zoneRepository'
import ZoneCharacter from './zoneCharacter' import characterManager from '../managers/characterManager'
import { ExtendedCharacter } from '../utilities/types'
class LoadedZone { class LoadedZone {
private readonly zone: Zone private readonly zone: Zone
private characters: ZoneCharacter[] = [] // private readonly npcs: ZoneNPC[] = []
constructor(zone: Zone) { constructor(zone: Zone) {
this.zone = zone this.zone = zone
@ -14,31 +15,10 @@ 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 zoneEventTileRepository.getAll(this.zone.id) const eventTiles = await zoneRepository.getEventTiles(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) => {
@ -49,6 +29,20 @@ 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

@ -1,19 +0,0 @@
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

@ -1,17 +0,0 @@
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 { appLogger } from '../utilities/logger' import { Character } from '@prisma/client'
class CharacterRepository { class CharacterRepository {
async getByUserId(userId: number) { async getByUserId(userId: number): Promise<Character[] | null> {
try { try {
return await prisma.character.findMany({ return await prisma.character.findMany({
where: { where: {
@ -14,22 +14,16 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get character by user ID: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get character by user ID: ${error.message}`)
return null
} }
} }
async getByUserAndId(userId: number, characterId: number) { async getByUserAndId(userId: number, characterId: number): Promise<Character | null> {
try { try {
return await prisma.character.findFirst({ return await prisma.character.findFirst({
where: { where: {
@ -42,22 +36,16 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get character by user ID and character ID: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get character by user ID and character ID: ${error.message}`)
return null
} }
} }
async getById(id: number) { async getById(id: number): Promise<Character | null> {
try { try {
return await prisma.character.findUnique({ return await prisma.character.findUnique({
where: { where: {
@ -69,22 +57,47 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get character by ID: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get character by ID: ${error.message}`)
return null
} }
} }
async getByName(name: string) { async updatePosition(id: number, positionX: number, positionY: number): Promise<Character | null> {
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: {
@ -96,18 +109,12 @@ class CharacterRepository {
include: { include: {
sprite: true sprite: true
} }
},
characterHair: {
include: {
sprite: true
}
} }
} }
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get character by name: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get character by name: ${error.message}`)
return null
} }
} }
} }

View File

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

View File

@ -1,49 +0,0 @@
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

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

View File

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

View File

@ -1,8 +1,5 @@
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> {
@ -11,28 +8,9 @@ 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,6 +1,5 @@
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> {
@ -12,8 +11,7 @@ class UserRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get user by ID: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get user by ID: ${error.message}`)
return null
} }
} }
@ -26,22 +24,7 @@ class UserRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
appLogger.error(`Failed to get user by username: ${error instanceof Error ? error.message : String(error)}`) throw new Error(`Failed to get user by username: ${error.message}`)
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

@ -1,19 +0,0 @@
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

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
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,5 @@
import { Character } from '@prisma/client'
class CharacterService {}
export default CharacterService

View File

@ -1,104 +0,0 @@
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

@ -1,30 +0,0 @@
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

@ -1,31 +0,0 @@
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,12 +1,7 @@
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
@ -20,145 +15,37 @@ class UserService {
* @param password * @param password
*/ */
async login(username: string, password: string): Promise<boolean | User> { async login(username: string, password: string): Promise<boolean | User> {
try { const user = await UserRepository.getByUsername(username)
const user = await UserRepository.getByUsername(username) if (!user) {
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, email: string, password: string): Promise<boolean | User> { async register(username: string, password: string): Promise<boolean | User> {
try { const user = await UserRepository.getByUsername(username)
const user = await UserRepository.getByUsername(username) if (user) {
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)
* Reset password return prisma.user.create({
* @param email data: {
*/ username,
async requestPasswordReset(email: string): Promise<boolean> { password: hashedPassword
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

@ -1,37 +0,0 @@
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,21 +1,18 @@
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 ZoneManager from '../managers/zoneManager' import CharacterManager from '../managers/characterManager'
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 loadedZone = ZoneManager.getZoneById(teleport.toZoneId) const zone = await ZoneRepository.getById(teleport.toZoneId)
if (!loadedZone) { if (!zone) return
gameLogger.error('zone:character:join error', 'Loaded zone not found')
return
}
const zone = loadedZone.getZone() // CharacterManager.moveCharacterBetweenZones(character, zone)
const oldZoneId = character.zoneId const oldZoneId = character.zoneId
const newZoneId = teleport.toZoneId const newZoneId = teleport.toZoneId
@ -26,21 +23,14 @@ export class ZoneEventTileService {
data: { data: {
zoneId: newZoneId, zoneId: newZoneId,
positionX: teleport.toPositionX, positionX: teleport.toPositionX,
positionY: teleport.toPositionY, positionY: teleport.toPositionY
rotation: teleport.toRotation
} }
}) })
// Update local character object // Update local character object
character.zoneId = newZoneId character.zoneId = newZoneId
character.rotation = teleport.toRotation
character.positionX = teleport.toPositionX character.positionX = teleport.toPositionX
character.positionY = teleport.toPositionY character.positionY = teleport.toPositionY
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)
@ -53,7 +43,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: loadedZone.getCharactersInZone() characters: CharacterManager.getCharactersInZone(zone)
}) })
} }
} }

View File

@ -1,38 +1,31 @@
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> {
try { const tiles = [
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']
['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
} }
}) })
gameLogger.info('Demo zone created.') console.log('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

@ -1,22 +0,0 @@
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,67 +1,24 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { TSocket } from '../../utilities/types' import { TSocket, ExtendedCharacter } from '../../utilities/types'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
import { gameLogger } from '../../utilities/logger' import CharacterManager from '../../managers/characterManager'
import ZoneManager from '../../managers/zoneManager'
import { CharacterService } from '../../services/characterService'
interface CharacterConnectPayload { type SocketResponseT = {
characterId: number character_id: number
characterHairId?: number
} }
export default class CharacterConnectEvent { export default function (io: Server, socket: TSocket) {
constructor( socket.on('character:connect', async (data: SocketResponseT) => {
private readonly io: Server, console.log('character:connect requested', data)
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:connect', this.handleCharacterConnect.bind(this))
}
private async handleCharacterConnect({ characterId, characterHairId }: CharacterConnectPayload): Promise<void> {
if (!this.socket.userId) {
this.emitError('User not authenticated')
return
}
try { try {
if (await this.hasActiveCharacter()) { const character = await CharacterRepository.getByUserAndId(socket?.user?.id as number, data.character_id)
this.emitError('You are already connected to another character') if (!character) return
return socket.characterId = character.id
}
// Update hair CharacterManager.initCharacter(character as ExtendedCharacter)
const characterService = new CharacterService() socket.emit('character:connect', character)
await characterService.updateHair(characterId, characterHairId ?? null) } catch (error: any) {
console.log('character:connect error', error)
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.emit('character:connect', character)
} catch (error) {
this.handleError('Failed to connect character', error) // @TODO : Make global error handler
} }
} })
private async hasActiveCharacter(): Promise<boolean> {
const characters = await CharacterRepository.getByUserId(this.socket.userId!)
return characters?.some((char) => ZoneManager.getCharacter(char.id)) ?? false
}
private 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,57 +2,50 @@ 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'
export default class CharacterCreateEvent { export default function (io: Server, socket: TSocket) {
constructor( socket.on('character:create', async (data: any) => {
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:create', this.handleCharacterCreate.bind(this))
}
private async handleCharacterCreate(data: any): Promise<any> {
console.log('character:create requested', data) console.log('character:create requested', data)
// zod validate // zod validate
try { try {
data = ZCharacterCreate.parse(data) data = ZCharacterCreate.parse(data)
const user_id = this.socket.userId! const user_id = socket.user?.id as number
// 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)
if (characterExists) { if (characterExists) {
return this.socket.emit('notification', { message: 'Character name already exists' }) return socket.emit('notification', { message: 'Character name already exists' })
} }
let characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[] let characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
if (characters.length >= 4) { if (characters.length >= 4) {
return this.socket.emit('notification', { message: 'You can only have 4 characters' }) return socket.emit('notification', { message: 'You can only have 4 characters' })
} }
const characterService = new CharacterService() const character: Character = await prisma.character.create({
const character: Character = await characterService.create(data.name, user_id) data: {
name: data.name,
userId: user_id
// characterTypeId: 1 // @TODO set to chosen character type
}
})
characters = [...characters, character] characters = [...characters, character]
this.socket.emit('character:create:success') socket.emit('character:create:success')
this.socket.emit('character:list', characters) socket.emit('character:list', characters)
gameLogger.info('character:create success') gameLogger.info('character:create success')
} catch (error: any) { } catch (error: any) {
console.log(error)
gameLogger.error(`character:create error: ${error.message}`) gameLogger.error(`character:create error: ${error.message}`)
if (error instanceof ZodError) { return socket.emit('notification', { message: 'Could not create character. Please try again (later).' })
return this.socket.emit('notification', { message: error.issues[0].message })
}
return this.socket.emit('notification', { message: 'Could not create character. Please try again (later).' })
} }
} })
} }

View File

@ -2,10 +2,9 @@ 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 = {
characterId: number character_id: number
} }
type TypeResponse = { type TypeResponse = {
@ -13,26 +12,19 @@ type TypeResponse = {
characters: Character[] characters: Character[]
} }
export default class CharacterDeleteEvent { export default function (io: Server, socket: TSocket) {
constructor( socket.on('character:delete', async (data: TypePayload, callback: (response: TypeResponse) => void) => {
private readonly io: Server, // zod validate
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:delete', this.handleCharacterDelete.bind(this))
}
private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> {
try { try {
const characterService = new CharacterService() await CharacterRepository.deleteByUserIdAndId(socket.user?.id as number, data.character_id as number)
await characterService.deleteByUserIdAndId(this.socket.userId!, data.characterId!)
const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[] const user_id = socket.user?.id as number
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
this.socket.emit('character:list', characters) socket.emit('character:list', characters)
} catch (error: any) { } catch (error: any) {
return this.socket.emit('notification', { message: 'Character delete failed. Please try again.' }) console.log(error)
return socket.emit('notification', { message: 'Character delete failed. Please try again.' })
} }
} })
} }

View File

@ -2,24 +2,16 @@ 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 function CharacterList(io: Server, socket: TSocket) {
constructor( socket.on('character:list', async (data: any) => {
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('character:list', this.handleCharacterList.bind(this))
}
private async handleCharacterList(data: any): Promise<void> {
try { try {
const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[] console.log('character:list requested')
this.socket.emit('character:list', characters) const user_id = socket.user?.id as number
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
socket.emit('character:list', characters)
} catch (error: any) { } catch (error: any) {
gameLogger.error('character:list error', error.message) console.log('character:list error', error)
} }
} })
} }

View File

@ -15,7 +15,7 @@ export default class AlertCommandEvent {
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('chat:message', this.handleAlertCommand.bind(this)) this.socket.on('chat:send_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,30 +24,25 @@ 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) {
return callback(false) 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(' ') })
return callback(true) 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

@ -1,60 +0,0 @@
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,10 +2,8 @@ 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 { gameLogger, gameMasterLogger } from '../../../utilities/logger' import CharacterManager from '../../../managers/characterManager'
import ZoneManager from '../../../managers/zoneManager' import { gameMasterLogger } from '../../../utilities/logger'
import ZoneCharacter from '../../../models/zoneCharacter'
import zoneManager from '../../../managers/zoneManager'
type TypePayload = { type TypePayload = {
message: string message: string
@ -18,23 +16,14 @@ export default class TeleportCommandEvent {
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('chat:message', this.handleTeleportCommand.bind(this)) this.socket.on('chat:send_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 {
// Check if character exists const character = CharacterManager.getCharacterFromSocket(this.socket)
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!) if (!character) {
if (!zoneCharacter) { this.socket.emit('notification', { title: 'Server message', message: 'Character not found' })
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
} }
@ -65,12 +54,10 @@ 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())
@ -78,15 +65,17 @@ export default class TeleportCommandEvent {
character.positionX = 0 character.positionX = 0
character.positionY = 0 character.positionY = 0
zoneCharacter.isMoving = false character.resetMovement = true
this.socket.emit('zone:character:teleport', { this.socket.emit('zone:character:teleport', {
zone, zone,
characters: ZoneManager.getZoneById(zone.id)?.getCharactersInZone() characters: CharacterManager.getCharactersInZone(zone)
}) })
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

@ -1,47 +0,0 @@
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

@ -1,47 +0,0 @@
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

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

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

View File

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

View File

@ -1,37 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,36 +0,0 @@
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

@ -1,51 +0,0 @@
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

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

View File

@ -1,40 +0,0 @@
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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,420 +2,145 @@ 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 type { Prisma, SpriteAction } from '@prisma/client' import type { Prisma, SpriteAction } from '@prisma/client'
import path from 'path'
import { writeFile, mkdir } from 'node:fs/promises' import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp' import sharp from 'sharp'
import { getPublicPath } from '../../../../utilities/storage' import CharacterManager from '../../../../managers/characterManager'
import CharacterRepository from '../../../../repositories/characterRepository'
import { gameMasterLogger } from '../../../../utilities/logger'
interface ContentBounds { type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
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[]
} }
interface Payload { type 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: ProcessedBuffer[] buffersWithDimensions: Array<{
buffer: Buffer
width: number | undefined
height: number | undefined
}>
} }
interface ProcessedBuffer { export default function (io: Server, socket: TSocket) {
buffer: Buffer socket.on('gm:sprite:update', async (data: Payload, callback: (success: boolean) => void) => {
width: number const character = CharacterManager.getCharacterFromSocket(socket)
height: number if (character?.role !== 'gm') {
} return callback(false)
interface SpriteDimensions {
width: number
height: number
baselineY: number
contentHeight: number
}
interface IsometricCenter {
centerX: number
verticalCenterLine: number
}
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(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:sprite:update', this.handleSpriteUpdate.bind(this))
}
private async handleSpriteUpdate(
data: Payload,
callback: (success: boolean) => void
): Promise<void> {
try { try {
const character = await CharacterRepository.getById(this.socket.characterId!) const parsedSpriteActions = validateSpriteActions(data.spriteActions)
if (character?.role !== 'gm') { const processedActions = await processSprites(parsedSpriteActions)
return callback(false)
}
const parsedActions = this.validateSpriteActions(data.spriteActions) await updateDatabase(data.id, data.name, processedActions)
const processedActions = await this.processSprites(parsedActions) await saveSpritesToDisk(data.id, processedActions)
await this.updateDatabase(data.id, data.name, processedActions)
await this.saveSpritesToDisk(data.id, processedActions)
callback(true) callback(true)
} catch (error) { } catch (error) {
gameMasterLogger.error(`Error updating sprite ${data.id}: ${error instanceof Error ? error.message : String(error)}`) console.error('Error updating sprite:', error)
callback(false) callback(false)
} }
} })
private validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
try {
const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
if (!Array.isArray(parsed)) {
throw new Error('spriteActions is not an array')
}
return parsed
} catch (error) {
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<{ function validateSpriteActions(spriteActions: Prisma.JsonValue): SpriteActionInput[] {
massCenter: number; try {
spinePosition: number; const parsed = JSON.parse(JSON.stringify(spriteActions)) as SpriteActionInput[]
contentBounds: ContentBounds; if (!Array.isArray(parsed)) {
}> { throw new Error('spriteActions is not an array')
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
// Find spine (densest vertical line in upper body) } catch (error) {
let maxDensity = 0; console.error('Error parsing spriteActions:', error)
let spinePosition = 0; throw error
for (let x = 0; x < width; x++) {
if (upperBodyDensity[x] > maxDensity) {
maxDensity = upperBodyDensity[x];
spinePosition = x;
}
}
// Calculate weighted mass center
const upperMassCenter = this.calculateMassCenter(upperBodyDensity);
const lowerMassCenter = this.calculateMassCenter(columnDensity);
const massCenter = Math.round(
upperMassCenter * this.ISOMETRIC.bodyRatios.weightUpper +
lowerMassCenter * this.ISOMETRIC.bodyRatios.weightLower
);
return {
massCenter,
spinePosition,
contentBounds: {
left: leftmost,
right: rightmost,
top: topmost,
bottom: bottommost,
width: rightmost - leftmost + 1,
height: bottommost - topmost + 1
}
};
} }
}
private async saveSpritesToDisk( async function processSprites(spriteActions: SpriteActionInput[]): Promise<ProcessedSpriteAction[]> {
id: string, return Promise.all(
processedActions: ProcessedSpriteAction[] spriteActions.map(async (spriteAction) => {
): Promise<void> { const { action, sprites } = spriteAction
const publicFolder = getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all( if (!Array.isArray(sprites) || sprites.length === 0) {
processedActions.map(async (action) => { throw new Error(`Invalid sprites array for action: ${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 const buffersWithDimensions = await Promise.all(
return sharp(background) sprites.map(async (sprite: string) => {
.composite( const buffer = Buffer.from(sprite.split(',')[1], 'base64')
frames.map((frame, index) => ({ const { width, height } = await sharp(buffer).metadata()
input: frame.buffer, return { buffer, width, height }
left: index * this.ISOMETRIC_SETTINGS.tileWidth, })
top: 0
}))
) )
.png()
.toBuffer();
}
private async updateDatabase( const frameWidth = Math.max(...buffersWithDimensions.map((b) => b.width || 0))
id: string, const frameHeight = Math.max(...buffersWithDimensions.map((b) => b.height || 0))
name: string,
processedActions: ProcessedSpriteAction[] return {
): Promise<void> { ...spriteAction,
await prisma.sprite.update({ frameWidth,
where: { id }, frameHeight,
data: { buffersWithDimensions
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
}))
}
} }
}) })
} )
} }
async function updateDatabase(id: string, name: string, processedActions: ProcessedSpriteAction[]) {
await prisma.sprite.update({
where: { id },
data: {
name,
spriteActions: {
deleteMany: { spriteId: id },
create: processedActions.map(({ action, sprites, originX, originY, isAnimated, isLooping, frameWidth, frameHeight, frameSpeed }) => ({
action,
sprites,
originX,
originY,
isAnimated,
isLooping,
frameWidth,
frameHeight,
frameSpeed
}))
}
}
})
}
async function saveSpritesToDisk(id: string, processedActions: ProcessedSpriteAction[]) {
const publicFolder = path.join(process.cwd(), 'public', 'sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
processedActions.map(async ({ action, buffersWithDimensions, frameWidth, frameHeight }) => {
const combinedImage = await sharp({
create: {
width: frameWidth * buffersWithDimensions.length,
height: frameHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite(
buffersWithDimensions.map(({ buffer }, index) => ({
input: buffer,
left: index * frameWidth,
top: 0
}))
)
.png()
.toBuffer()
const filename = path.join(publicFolder, `${action}.png`)
await writeFile(filename, combinedImage)
})
)
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 ZoneRepository from '../../../repositories/zoneRepository' import ZoneRepository from '../../../repositories/zoneRepository'
import { Zone, ZoneEffect, ZoneEventTileType, ZoneObject } from '@prisma/client' import { Zone, ZoneEventTileType, ZoneObject } from '@prisma/client'
import prisma from '../../../utilities/prisma' import prisma from '../../../utilities/prisma'
import zoneManager from '../../../managers/zoneManager' import zoneManager from '../../../managers/zoneManager'
import CharacterRepository from '../../../repositories/characterRepository' import CharacterRepository from '../../../repositories/characterRepository'
@ -22,13 +22,8 @@ interface IPayload {
toZoneId: number toZoneId: number
toPositionX: number toPositionX: number
toPositionY: number toPositionY: number
toRotation: number
} }
}[] }[]
zoneEffects: {
effect: string
strength: number
}[]
zoneObjects: ZoneObject[] zoneObjects: ZoneObject[]
} }
@ -39,52 +34,40 @@ export default class ZoneUpdateEvent {
) {} ) {}
public listen(): void { public listen(): void {
this.socket.on('gm:zone_editor:zone:update', this.handleEvent.bind(this)) this.socket.on('gm:zone_editor:zone:update', this.handleZoneUpdate.bind(this))
} }
private async handleEvent(data: IPayload, callback: (response: Zone | null) => void): Promise<void> { private async handleZoneUpdate(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')
return callback(null) 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.`)
return callback(null) 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.`)
return callback(null) 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.`)
return callback(null) 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: {
@ -101,15 +84,14 @@ export default class ZoneUpdateEvent {
positionY: zoneEventTile.positionY, positionY: zoneEventTile.positionY,
...(zoneEventTile.type === 'TELEPORT' && zoneEventTile.teleport ...(zoneEventTile.type === 'TELEPORT' && zoneEventTile.teleport
? { ? {
teleport: { teleport: {
create: { create: {
toZoneId: zoneEventTile.teleport.toZoneId, toZoneId: zoneEventTile.teleport.toZoneId,
toPositionX: zoneEventTile.teleport.toPositionX, toPositionX: zoneEventTile.teleport.toPositionX,
toPositionY: zoneEventTile.teleport.toPositionY, toPositionY: zoneEventTile.teleport.toPositionY
toRotation: zoneEventTile.teleport.toRotation
}
} }
} }
}
: {}) : {})
})) }))
}, },
@ -118,19 +100,10 @@ export default class ZoneUpdateEvent {
create: data.zoneObjects.map((zoneObject) => ({ create: data.zoneObjects.map((zoneObject) => ({
objectId: zoneObject.objectId, objectId: zoneObject.objectId,
depth: zoneObject.depth, depth: zoneObject.depth,
isRotated: zoneObject.isRotated,
positionX: zoneObject.positionX, positionX: zoneObject.positionX,
positionY: zoneObject.positionY positionY: zoneObject.positionY
})) }))
}, }
zoneEffects: {
deleteMany: { zoneId: data.zoneId },
create: data.zoneEffects.map((zoneEffect) => ({
effect: zoneEffect.effect,
strength: zoneEffect.strength
}))
},
updatedAt: new Date()
} }
}) })
@ -142,18 +115,13 @@ export default class ZoneUpdateEvent {
return return
} }
gameMasterLogger.info(`User ${character.id} has updated zone via zone editor.`)
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 instanceof Error ? error.message : String(error)}`) gameMasterLogger.error('gm:zone_editor:zone:update error', error.message)
callback(null) callback(null)
} }
} }
} }

View File

@ -1,7 +1,6 @@
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(
@ -15,15 +14,15 @@ export default class LoginEvent {
private handleLogin(): void { private handleLogin(): void {
try { try {
if (!this.socket.userId) { if (!this.socket.user) {
gameLogger.warn('Login attempt without user data') gameLogger.warn('Login attempt without user data')
return return
} }
this.socket.emit('logged_in', { user: UserRepository.getById(this.socket.userId) }) this.socket.emit('logged_in', { user: this.socket.user })
gameLogger.info(`User logged in: ${this.socket.userId}`) gameLogger.info(`User logged in: ${this.socket.user.id}`)
} catch (error: any) { } catch (error: any) {
gameLogger.error('login error', error.message) gameLogger.error('login error', error.message)
} }
} }
} }

View File

@ -1,16 +1,13 @@
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 { Zone } from '@prisma/client' import { Character, 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 ZoneManager from '../../managers/zoneManager'
import zoneCharacter from '../../models/zoneCharacter'
import zoneManager from '../../managers/zoneManager'
interface IResponse { interface IResponse {
zone: Zone zone: Zone
characters: zoneCharacter[] characters: Character[]
} }
export default class CharacterJoinEvent { export default class CharacterJoinEvent {
@ -25,50 +22,32 @@ export default class CharacterJoinEvent {
private async handleCharacterJoin(callback: (response: IResponse) => void): Promise<void> { private async handleCharacterJoin(callback: (response: IResponse) => void): Promise<void> {
try { try {
if (!this.socket.characterId) { if (!this.socket.characterId) return
gameLogger.error('zone:character:join error', 'Zone requested but no character id set')
return
}
const character = await CharacterRepository.getById(this.socket.characterId) const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) { if (!character) return
gameLogger.error('zone:character:join error', 'Character not found')
return
}
/**
* @TODO: If zone is not found, spawn back to the start
*/
const zone = await ZoneRepository.getById(character.zoneId) 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
} }
/** if (character.zoneId) {
* @TODO: If zone is not found, spawn back to the start this.socket.leave(character.zoneId.toString())
*/ this.io.to(character.zoneId.toString()).emit('zone:character:leave', character)
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', zoneManager.getCharacter(character.id)) this.io.to(zone.id.toString()).emit('zone:character:join', character)
// Log // send over zone and characters to socket
gameLogger.info(`User ${character.id} joined zone ${zone.id}`) callback({ zone, characters: CharacterManager.getCharactersInZone(zone) })
// Send over zone and characters to socket
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,9 +1,8 @@
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(
@ -17,29 +16,21 @@ export default class ZoneLeaveEvent {
private async handleZoneLeave(): Promise<void> { private async handleZoneLeave(): Promise<void> {
try { try {
if (!this.socket.characterId) { const character = CharacterManager.getCharacterFromSocket(this.socket)
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:join error', 'Character not found') gameLogger.error('zone:character:leave 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) {
gameLogger.error('zone:character:join error', 'Zone not found')
return
}
const loadedZone = ZoneManager.getZoneById(zone.id) if (!zone) {
if (!loadedZone) { gameLogger.error('zone:character:leave error', 'Zone not found')
gameLogger.error('zone:character:join error', 'Loaded zone not found')
return return
} }
@ -49,7 +40,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 loadedZone.removeCharacter(character.id) await CharacterManager.removeCharacter(character)
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,96 +1,144 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { TSocket, ZoneEventTileWithTeleport } from '../../utilities/types' import { TSocket, ExtendedCharacter } from '../../utilities/types'
import { CharacterService } from '../../services/characterService' import { CharacterMoveService } from '../../services/character/characterMoveService'
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 ZoneManager from '../../managers/zoneManager' import QueueManager from '../../managers/queueManager'
import ZoneCharacter from '../../models/zoneCharacter'
import zoneEventTileRepository from '../../repositories/zoneEventTileRepository' export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
}
export default class CharacterMove { export default class CharacterMove {
private readonly characterService = new CharacterService() private characterMoveService: CharacterMoveService
private readonly zoneEventTileService = new ZoneEventTileService() private zoneEventTileService: 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:move', this.handleCharacterMove.bind(this)) this.socket.on('character:initMove', 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> {
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!) let character = CharacterManager.getCharacterFromSocket(this.socket)
if (!zoneCharacter?.character) { if (!character) {
gameLogger.error('character:move error', 'Character not found or not initialized')
return
}
// If already moving, cancel current movement and wait for it to fully stop
if (zoneCharacter.isMoving) {
zoneCharacter.isMoving = false
await new Promise((resolve) => setTimeout(resolve, 100))
}
const path = await this.characterService.calculatePath(zoneCharacter.character, positionX, positionY)
if (!path) {
this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:moveError', 'No valid path found')
return
}
// Start new movement
zoneCharacter.isMoving = true
zoneCharacter.currentPath = path // Add this property to ZoneCharacter class
await this.moveAlongPath(zoneCharacter, path)
}
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++) {
// Exit if movement was cancelled or interrupted
if (!zoneCharacter.isMoving || zoneCharacter.currentPath !== path) {
return
}
const [start, end] = [path[i], path[i + 1]]
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y)
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
}
this.characterService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', zoneCharacter)
await this.characterService.applyMovementDelay()
}
// Only finalize if this path wasn't interrupted
if (zoneCharacter.isMoving && zoneCharacter.currentPath === path) {
this.finalizeMovement(zoneCharacter)
}
}
private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> {
const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!zoneCharacter) {
gameLogger.error('character:move error', 'Character not found') gameLogger.error('character:move error', 'Character not found')
return return
} }
if (zoneEventTile.teleport) { if (!character) {
await this.zoneEventTileService.handleTeleport(this.io, this.socket, zoneCharacter.character, zoneEventTile.teleport) gameLogger.error('character:move error', 'character has not been initialized?')
return
}
const path = await this.characterMoveService.calculatePath(character, positionX, positionY)
if (!path) {
this.io.in(character.zoneId.toString()).emit('character:moveError', 'No valid path found')
return
}
if (!character.isMoving && character.resetMovement) {
character.resetMovement = false
}
if (character.isMoving && !character.resetMovement) {
character.resetMovement = true
this.nextPath[character.id] = path
}
if (!character.isMoving && !character.resetMovement) {
character.isMoving = true
this.currentZoneId[character.id] = character.zoneId
await this.moveAlongPath(character, path)
} }
} }
private finalizeMovement(zoneCharacter: ZoneCharacter): void { private async moveAlongPath(character: ExtendedCharacter, path: Array<{ x: number; y: number }>): Promise<void> {
zoneCharacter.isMoving = false for (let i = 0; i < path.length - 1; i++) {
this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:move', zoneCharacter) const start = path[i]
const end = path[i + 1]
// if (!(await this.movementValidator.isValidMove(character, end))) {
// break
// }
if (CharacterManager.hasResetMovement(character)) {
break
}
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y)
const zoneEventTile = await prisma.zoneEventTile.findFirst({
where: {
zoneId: character.zoneId,
positionX: Math.floor(end.x),
positionY: Math.floor(end.y)
}
})
if (zoneEventTile) {
if (zoneEventTile.type === 'BLOCK') {
break
}
if (zoneEventTile.type === 'TELEPORT') {
const teleportTile = (await prisma.zoneEventTile.findFirst({
where: { id: zoneEventTile.id },
include: { teleport: true }
})) as ZoneEventTileWithTeleport
if (teleportTile) {
await this.handleZoneEventTile(teleportTile)
break
}
}
}
this.characterMoveService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', character)
await this.characterMoveService.applyMovementDelay()
}
if (CharacterManager.hasResetMovement(character)) {
character.resetMovement = false
if (this.currentZoneId[character.id] === character.zoneId) {
await this.moveAlongPath(character, this.nextPath[character.id])
} else {
delete this.currentZoneId[character.id]
character.isMoving = false
}
} else {
this.finalizeMovement(character)
}
}
private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket)
if (!character) {
gameLogger.error('character:move error', 'Character not found')
return
}
const teleport = zoneEventTile.teleport
if (teleport) {
await this.zoneEventTileService.handleTeleport(this.io, this.socket, character, teleport)
return
}
}
private finalizeMovement(character: ExtendedCharacter): void {
character.isMoving = false
this.io.in(character.zoneId.toString()).emit('character:move', character)
} }
} }

View File

@ -1,24 +0,0 @@
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,31 +2,33 @@ 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) {
return 7 rotation = 7
} else if (X1 < X2 && Y1 < Y2) { } else if (X1 < X2 && Y1 < Y2) {
return 3 rotation = 3
} else if (X1 > X2 && Y1 < Y2) { } else if (X1 > X2 && Y1 < Y2) {
return 5 rotation = 5
} else if (X1 < X2 && Y1 > Y2) { } else if (X1 < X2 && Y1 > Y2) {
return 1 rotation = 1
} }
} }
// Non-diagonal movements if (rotation === 0) {
if (X1 > X2) { if (X1 > X2) {
return 6 rotation = 6
} else if (X1 < X2) { } else if (X1 < X2) {
return 2 rotation = 2
} else if (Y1 < Y2) { } else if (Y1 < Y2) {
return 4 rotation = 4
} else if (Y1 > Y2) { } else if (Y1 > Y2) {
return 0 rotation = 0
}
} }
return 0 // Default case return rotation
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { Socket } from 'socket.io' import { Socket } from 'socket.io'
import { Character, User, ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client' import { Character, User } from '@prisma/client'
export type TSocket = Socket & { export type TSocket = Socket & {
userId?: number user?: User
characterId?: number characterId?: number
handshake?: { handshake?: {
query?: { query?: {
@ -18,31 +18,17 @@ export type TSocket = Socket & {
export type ExtendedCharacter = Character & { export type ExtendedCharacter = Character & {
isMoving?: boolean isMoving?: boolean
resetMovement?: boolean resetMovement: boolean
} }
export type ZoneEventTileWithTeleport = ZoneEventTile & { export type TAsset = {
teleport: ZoneEventTileTeleport
}
export type AssetData = {
key: string key: string
data: string url: 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
isAnimated?: boolean
frameCount?: number
frameWidth?: number frameWidth?: number
frameHeight?: number frameHeight?: number
} }
export type WorldSettings = {
date: Date
isRainEnabled: boolean
isFogEnabled: boolean
fogDensity: number
}
// export type TCharacter = Socket & { // export type TCharacter = Socket & {
// user?: User // user?: User
// character?: Character // character?: Character

View File

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

View File

@ -20,29 +20,6 @@ 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, {

View File

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