#174: Refactor character manager into zoneManager for better DX, major refactor of time and weather system (data is stored in DB now instead of JSON file), npm update, npm format, many other improvements

This commit is contained in:
Dennis Postma 2024-11-13 13:21:01 +01:00
parent 628b3bf1fa
commit d4e0cbe398
43 changed files with 465 additions and 461 deletions

12
package-lock.json generated
View File

@ -949,9 +949,9 @@
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "5.24.0", "version": "5.25.6",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.24.0.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.25.6.tgz",
"integrity": "sha512-rNWOg4opfHOhZjWWr1aIjfw2nUFB91F9qwIT49CdRypL4FznmHAqamTnw2EcZlj2KeFswV50tisZwq/h1yMUAw==", "integrity": "sha512-jxpa/DB02V20CqBAgyqpQazT630CJm0r4fky8EchH3mcJAomRtKXLS6tRA0J8tb29BDGlr/LXhlUuZwdBJBSdA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.6.0", "cron-parser": "^4.6.0",
@ -2064,9 +2064,9 @@
} }
}, },
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.2", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
"integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"

View File

@ -1,3 +1,14 @@
-- CreateTable
CREATE TABLE `World` (
`date` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`isRainEnabled` BOOLEAN NOT NULL DEFAULT false,
`rainPercentage` INTEGER NOT NULL DEFAULT 0,
`isFogEnabled` BOOLEAN NOT NULL DEFAULT false,
`fogDensity` INTEGER NOT NULL DEFAULT 0,
UNIQUE INDEX `World_date_key`(`date`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable -- CreateTable
CREATE TABLE `Chat` ( CREATE TABLE `Chat` (
`id` INTEGER NOT NULL AUTO_INCREMENT, `id` INTEGER NOT NULL AUTO_INCREMENT,

View File

@ -0,0 +1,7 @@
model World {
date DateTime @unique @default(now())
isRainEnabled Boolean @default(false)
rainPercentage Int @default(0)
isFogEnabled Boolean @default(false)
fogDensity Int @default(0)
}

View File

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

View File

@ -1,7 +1,7 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { appLogger } from '../utilities/logger' import { appLogger } from '../utilities/logger'
import { getRootPath } from '../utilities/storage' import prisma from '../utilities/prisma'
import { readJsonValue, setJsonValue } from '../utilities/json' import worldService from '../services/worldService'
class DateManager { class DateManager {
private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours
@ -18,7 +18,6 @@ class DateManager {
appLogger.info('Date manager loaded') appLogger.info('Date manager loaded')
} }
// When a GM sets the time, update the current date and update the world file
public async setTime(time: string): Promise<void> { public async setTime(time: string): Promise<void> {
try { try {
let newDate: Date let newDate: Date
@ -45,8 +44,13 @@ class DateManager {
private async loadDate(): Promise<void> { private async loadDate(): Promise<void> {
try { try {
const dateString = await readJsonValue<string>(this.getWorldFilePath(), 'date') const world = await prisma.world.findFirst({
this.currentDate = new Date(dateString) orderBy: { date: 'desc' }
})
if (world) {
this.currentDate = world.date
}
} catch (error) { } catch (error) {
appLogger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`)
this.currentDate = new Date() // Use current date as fallback this.currentDate = new Date() // Use current date as fallback
@ -57,7 +61,7 @@ class DateManager {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.advanceGameTime() this.advanceGameTime()
this.emitDate() this.emitDate()
this.saveDate() void this.saveDate()
}, DateManager.UPDATE_INTERVAL) }, DateManager.UPDATE_INTERVAL)
} }
@ -72,14 +76,22 @@ class DateManager {
private async saveDate(): Promise<void> { private async saveDate(): Promise<void> {
try { try {
await setJsonValue(this.getWorldFilePath(), 'date', this.currentDate) await worldService.update({
date: this.currentDate
})
} catch (error) { } catch (error) {
appLogger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
private getWorldFilePath(): string { public cleanup(): void {
return getRootPath('data', 'world.json') if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public getCurrentDate(): Date {
return this.currentDate
} }
} }

View File

@ -1,7 +1,7 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { appLogger } from '../utilities/logger' import { appLogger } from '../utilities/logger'
import { getRootPath } from '../utilities/storage' import prisma from '../utilities/prisma'
import { readJsonValue, setJsonValue } from '../utilities/json' import worldService from '../services/worldService'
interface WeatherState { interface WeatherState {
isRainEnabled: boolean isRainEnabled: boolean
@ -37,46 +37,48 @@ class WeatherManager {
? Math.floor(Math.random() * 50) + 50 // 50-100% ? Math.floor(Math.random() * 50) + 50 // 50-100%
: 0 : 0
// Save weather
await this.saveWeather() await this.saveWeather()
// Emit weather
this.emitWeather() this.emitWeather()
} }
public async toggleFog(): Promise<void> { public async toggleFog(): Promise<void> {
this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled
this.weatherState.fogDensity = this.weatherState.isFogEnabled this.weatherState.fogDensity = this.weatherState.isFogEnabled
? Math.random() * 0.7 + 0.3 // 0.3-1.0 ? Math.floor((Math.random() * 0.7 + 0.3) * 100) // Convert 0.3-1.0 to 30-100
: 0 : 0
// Save weather
await this.saveWeather() await this.saveWeather()
// Emit weather
this.emitWeather() this.emitWeather()
} }
private async loadWeather(): Promise<void> { private async loadWeather(): Promise<void> {
try { try {
this.weatherState.isRainEnabled = await readJsonValue<boolean>(this.getWorldFilePath(), 'isRainEnabled') const world = await prisma.world.findFirst({
this.weatherState.rainPercentage = await readJsonValue<number>(this.getWorldFilePath(), 'rainPercentage') orderBy: { date: 'desc' }
this.weatherState.isFogEnabled = await readJsonValue<boolean>(this.getWorldFilePath(), 'isFogEnabled') })
this.weatherState.fogDensity = await readJsonValue<number>(this.getWorldFilePath(), 'fogDensity')
if (world) {
this.weatherState = {
isRainEnabled: world.isRainEnabled,
rainPercentage: world.rainPercentage,
isFogEnabled: world.isFogEnabled,
fogDensity: world.fogDensity
}
}
} catch (error) { } catch (error) {
appLogger.error(`Failed to load weather: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to load weather: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
public async getWeatherState(): Promise<WeatherState> { public getWeatherState(): WeatherState {
return this.weatherState return this.weatherState
} }
private startWeatherLoop(): void { private startWeatherLoop(): void {
this.intervalId = setInterval(() => { this.intervalId = setInterval(async () => {
this.updateWeather() this.updateWeather()
this.emitWeather() this.emitWeather()
this.saveWeather().catch((error) => { await this.saveWeather().catch((error) => {
appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
}) })
}, WeatherManager.UPDATE_INTERVAL) }, WeatherManager.UPDATE_INTERVAL)
@ -95,7 +97,7 @@ class WeatherManager {
if (Math.random() < WeatherManager.FOG_CHANCE) { if (Math.random() < WeatherManager.FOG_CHANCE) {
this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled this.weatherState.isFogEnabled = !this.weatherState.isFogEnabled
this.weatherState.fogDensity = this.weatherState.isFogEnabled this.weatherState.fogDensity = this.weatherState.isFogEnabled
? Math.random() * 0.7 + 0.3 // 0.3-1.0 ? Math.floor((Math.random() * 0.7 + 0.3) * 100) // Convert 0.3-1.0 to 30-100
: 0 : 0
} }
} }
@ -106,20 +108,21 @@ class WeatherManager {
private async saveWeather(): Promise<void> { private async saveWeather(): Promise<void> {
try { try {
const promises = [ await worldService.update({
await setJsonValue(this.getWorldFilePath(), 'isRainEnabled', this.weatherState.isRainEnabled), isRainEnabled: this.weatherState.isRainEnabled,
await setJsonValue(this.getWorldFilePath(), 'rainPercentage', this.weatherState.rainPercentage), rainPercentage: this.weatherState.rainPercentage,
await setJsonValue(this.getWorldFilePath(), 'isFogEnabled', this.weatherState.isFogEnabled), isFogEnabled: this.weatherState.isFogEnabled,
await setJsonValue(this.getWorldFilePath(), 'fogDensity', this.weatherState.fogDensity) fogDensity: this.weatherState.fogDensity
] })
await Promise.all(promises)
} catch (error) { } catch (error) {
appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`) appLogger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
private getWorldFilePath(): string { public cleanup(): void {
return getRootPath('data', 'world.json') if (this.intervalId) {
clearInterval(this.intervalId)
}
} }
} }

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { Zone } from '@prisma/client' import { Character, Zone } from '@prisma/client'
import zoneRepository from '../repositories/zoneRepository' import zoneRepository from '../repositories/zoneRepository'
import ZoneCharacter from './zoneCharacter'
class LoadedZone { class LoadedZone {
private readonly zone: Zone private readonly zone: Zone
// private readonly npcs: ZoneNPC[] = [] private characters: ZoneCharacter[] = []
constructor(zone: Zone) { constructor(zone: Zone) {
this.zone = zone this.zone = zone
@ -13,6 +14,27 @@ 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))
@ -27,20 +49,6 @@ class LoadedZone {
return grid return grid
} }
/**
* @TODO: Implement this
* @param position
*/
public async isPositionWalkable(position: { x: number; y: number }): Promise<boolean> {
const grid = await this.getGrid()
if (!grid?.length) return false
const gridX = Math.floor(position.x)
const gridY = Math.floor(position.y)
return grid[gridY]?.[gridX] === 1 || grid[gridY]?.[Math.ceil(position.x)] === 1 || grid[Math.ceil(position.y)]?.[gridX] === 1 || grid[Math.ceil(position.y)]?.[Math.ceil(position.x)] === 1
}
} }
export default LoadedZone export default LoadedZone

View File

@ -0,0 +1,25 @@
import { Character } from '@prisma/client'
import prisma from '../utilities/prisma'
class ZoneCharacter {
public readonly character: Character
public isMoving: boolean = false
constructor(character: Character) {
this.character = character
}
public async savePosition() {
await prisma.character.update({
where: { id: this.character.id },
data: {
positionX: this.character.positionX,
positionY: this.character.positionY,
rotation: this.character.rotation,
zoneId: this.character.zoneId
}
})
}
}
export default ZoneCharacter

View File

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

View File

@ -1,4 +1,5 @@
import prisma from '../utilities/prisma' // Import the global Prisma instance import prisma from '../utilities/prisma'
import { appLogger } from '../utilities/logger' // Import the global Prisma instance
class PasswordResetTokenRepository { class PasswordResetTokenRepository {
async getById(id: number): Promise<any> { async getById(id: number): Promise<any> {
@ -10,7 +11,7 @@ class PasswordResetTokenRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
throw new Error(`Failed to get password reset token by ID: ${error.message}`) appLogger.error(`Failed to get password reset token by ID: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
@ -23,7 +24,7 @@ class PasswordResetTokenRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
throw new Error(`Failed to get password reset token by user ID: ${error.message}`) appLogger.error(`Failed to get password reset token by user ID: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
@ -36,7 +37,7 @@ class PasswordResetTokenRepository {
}) })
} catch (error: any) { } catch (error: any) {
// Handle error // Handle error
throw new Error(`Failed to get password reset token by token: ${error.message}`) appLogger.error(`Failed to get password reset token by token: ${error instanceof Error ? error.message : String(error)}`)
} }
} }
} }

View File

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

View File

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

View File

@ -1,20 +1,9 @@
import { Zone, ZoneEventTile, ZoneEventTileType, ZoneObject } from '@prisma/client' import { Zone, ZoneEventTile, ZoneEventTileType, ZoneObject } from '@prisma/client'
import prisma from '../utilities/prisma' import prisma from '../utilities/prisma'
import { ZoneEventTileWithTeleport } from '../socketEvents/zone/characterMove' import { ZoneEventTileWithTeleport } from '../utilities/types'
import { appLogger } from '../utilities/logger' import { appLogger } from '../utilities/logger'
import { AssetData } from '../utilities/types'
import tileRepository from './tileRepository'
class ZoneRepository { class ZoneRepository {
async getFirst(): Promise<Zone | null> {
try {
return await prisma.zone.findFirst()
} catch (error: any) {
appLogger.error(`Failed to get first zone: ${error.message}`)
return null
}
}
async getAll(): Promise<Zone[]> { async getAll(): Promise<Zone[]> {
try { try {
return await prisma.zone.findMany() return await prisma.zone.findMany()

View File

@ -13,7 +13,6 @@ import { appLogger, watchLogs } from './utilities/logger'
import ZoneManager from './managers/zoneManager' import ZoneManager from './managers/zoneManager'
import UserManager from './managers/userManager' import UserManager from './managers/userManager'
import CommandManager from './managers/commandManager' import CommandManager from './managers/commandManager'
import CharacterManager from './managers/characterManager'
import QueueManager from './managers/queueManager' import QueueManager from './managers/queueManager'
import DateManager from './managers/dateManager' import DateManager from './managers/dateManager'
import WeatherManager from './managers/weatherManager' import WeatherManager from './managers/weatherManager'
@ -30,7 +29,7 @@ export class Server {
this.app = express() this.app = express()
this.app.use( this.app.use(
cors({ cors({
origin: config.CLIENT_URL origin: config.CLIENT_URL // Allow CORS from the client URL
}) })
) )
this.app.use(express.json()) this.app.use(express.json())
@ -81,9 +80,6 @@ export class Server {
// Load zoneEditor manager // Load zoneEditor manager
await ZoneManager.boot() await ZoneManager.boot()
// Load character manager
await CharacterManager.boot()
// Load command manager // Load command manager
await CommandManager.boot(this.io) await CommandManager.boot(this.io)

View File

@ -1,43 +1,57 @@
import { ExtendedCharacter } from '../../utilities/types'
import { AStar } from '../../utilities/character/aStar' import { AStar } from '../../utilities/character/aStar'
import ZoneManager from '../../managers/zoneManager' import ZoneManager from '../../managers/zoneManager'
import Rotation from '../../utilities/character/rotation' import Rotation from '../../utilities/character/rotation'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import { Character } from '@prisma/client'
interface Position {
x: number
y: number
}
export class CharacterMoveService { export class CharacterMoveService {
public updatePosition(character: ExtendedCharacter, position: { x: number; y: number }, newZoneId?: number) { private static readonly MOVEMENT_DELAY_MS = 250
public updatePosition(character: Character, position: Position, newZoneId?: number): void {
if (!this.isValidPosition(position)) {
gameLogger.error(`Invalid position coordinates: ${position.x}, ${position.y}`)
}
Object.assign(character, { Object.assign(character, {
positionX: position.x, positionX: position.x,
positionY: position.y, positionY: position.y,
rotation: Rotation.calculate(character.positionX, character.positionY, position.x, position.y), rotation: Rotation.calculate(character.positionX, character.positionY, position.x, position.y),
zoneId: newZoneId || character.zoneId 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> { public async calculatePath(character: Character, targetX: number, targetY: number): Promise<Position[] | null> {
const grid = await ZoneManager.getZoneById(character.zoneId)?.getGrid() const zone = ZoneManager.getZoneById(character.zoneId)
const grid = await zone?.getGrid()
if (!grid?.length) { if (!grid?.length) {
gameLogger.error('character:move error', 'Grid not found or empty') gameLogger.error('character:move error', 'Grid not found or empty')
return null return null
} }
const start = { x: Math.floor(character.positionX), y: Math.floor(character.positionY) } const start: Position = {
const end = { x: Math.floor(targetX), y: Math.floor(targetY) } 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) return AStar.findPath(start, end, grid)
} }
public async applyMovementDelay(): Promise<void> { public async applyMovementDelay(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 250)) // 250ms delay between steps await new Promise((resolve) => setTimeout(resolve, CharacterMoveService.MOVEMENT_DELAY_MS))
}
private isValidPosition(position: Position): boolean {
return Number.isFinite(position.x) && Number.isFinite(position.y) && position.x >= 0 && position.y >= 0
} }
} }

View File

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

View File

@ -3,7 +3,7 @@ import prisma from '../utilities/prisma'
import ZoneRepository from '../repositories/zoneRepository' import ZoneRepository from '../repositories/zoneRepository'
import { ZoneEventTileTeleport } from '@prisma/client' import { ZoneEventTileTeleport } from '@prisma/client'
import { Server } from 'socket.io' import { Server } from 'socket.io'
import CharacterManager from '../managers/characterManager' import ZoneManager from '../managers/zoneManager'
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> {
@ -12,8 +12,6 @@ export class ZoneEventTileService {
const zone = await ZoneRepository.getById(teleport.toZoneId) const zone = await ZoneRepository.getById(teleport.toZoneId)
if (!zone) return if (!zone) return
// CharacterManager.moveCharacterBetweenZones(character, zone)
const oldZoneId = character.zoneId const oldZoneId = character.zoneId
const newZoneId = teleport.toZoneId const newZoneId = teleport.toZoneId
@ -46,7 +44,7 @@ export class ZoneEventTileService {
// Send teleport information to the client // Send teleport information to the client
socket.emit('zone:character:teleport', { socket.emit('zone:character:teleport', {
zone, zone,
characters: CharacterManager.getCharactersInZone(zone) characters: ZoneManager.getZoneById(zone.id)?.getCharactersInZone()
}) })
} }
} }

View File

@ -1,17 +1,9 @@
import prisma from '../utilities/prisma' import prisma from '../utilities/prisma'
import { AssetData } from '../utilities/types' import { gameLogger } from '../utilities/logger'
import tileRepository from '../repositories/tileRepository'
import zoneRepository from '../repositories/zoneRepository'
import { Object, Zone, ZoneObject } from '@prisma/client'
type getZoneAsetsZoneType = Zone & {
zoneObjects: (ZoneObject & {
object: Object
})[]
}
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'],
@ -34,51 +26,13 @@ class ZoneService {
} }
}) })
console.log('Demo zone created.') gameLogger.info('Demo zone created.')
return true return true
} catch (error: any) {
gameLogger.error(`Failed to create demo zone: ${error instanceof Error ? error.message : String(error)}`)
return false
} }
async getZoneAssets(zone: getZoneAsetsZoneType): Promise<AssetData[]> {
const assets: AssetData[] = []
// zone.tiles is prisma jsonvalue
let tiles = JSON.parse(JSON.stringify(zone.tiles))
tiles = [...new Set(tiles.flat())]
// Add tile assets
for (const tile of tiles) {
const tileInfo = await tileRepository.getById(tile)
if (!tileInfo) continue
assets.push({
key: tileInfo.id,
data: '/assets/tiles/' + tileInfo.id + '.png',
group: 'tiles',
updatedAt: tileInfo?.updatedAt || new Date()
} as AssetData)
}
// Add object assets
for (const zoneObject of zone.zoneObjects) {
if (!zoneObject.object) continue
assets.push({
key: zoneObject.object.id,
data: '/assets/objects/' + zoneObject.object.id + '.png',
group: 'objects',
updatedAt: zoneObject.object.updatedAt || new Date()
} as AssetData)
}
// Filter out duplicate assets
return assets.reduce((acc: AssetData[], current) => {
const x = acc.find((item) => item.key === current.key && item.group === current.group)
if (!x) {
return acc.concat([current])
} else {
return acc
}
}, [])
} }
} }

View File

@ -3,7 +3,7 @@ import { TSocket } from '../../utilities/types'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
type SocketResponseT = { type SocketResponseT = {
character_id: number characterId: number
} }
export default class CharacterConnectEvent { export default class CharacterConnectEvent {
@ -19,7 +19,7 @@ export default class CharacterConnectEvent {
private async handleCharacterConnect(data: SocketResponseT): Promise<void> { private async handleCharacterConnect(data: SocketResponseT): Promise<void> {
console.log('character:connect requested', data) console.log('character:connect requested', data)
try { try {
const character = await CharacterRepository.getByUserAndId(this.socket?.user?.id as number, data.character_id) const character = await CharacterRepository.getByUserAndId(this.socket?.userId!, data.characterId!)
if (!character) return if (!character) return
this.socket.characterId = character.id this.socket.characterId = character.id

View File

@ -23,7 +23,7 @@ export default class CharacterCreateEvent {
try { try {
data = ZCharacterCreate.parse(data) data = ZCharacterCreate.parse(data)
const user_id = this.socket.user?.id as number const user_id = this.socket.userId!
// Check if character name already exists // Check if character name already exists
const characterExists = await CharacterRepository.getByName(data.name) const characterExists = await CharacterRepository.getByName(data.name)

View File

@ -4,7 +4,7 @@ import { Character, Zone } from '@prisma/client'
import CharacterRepository from '../../repositories/characterRepository' import CharacterRepository from '../../repositories/characterRepository'
type TypePayload = { type TypePayload = {
character_id: number characterId: number
} }
type TypeResponse = { type TypeResponse = {
@ -23,12 +23,10 @@ export default class CharacterDeleteEvent {
} }
private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> { private async handleCharacterDelete(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> {
// zod validate
try { try {
await CharacterRepository.deleteByUserIdAndId(this.socket.user?.id as number, data.character_id as number) await CharacterRepository.deleteByUserIdAndId(this.socket.userId!, data.characterId!)
const user_id = this.socket.user?.id as number const characters: Character[] = (await CharacterRepository.getByUserId(this.socket.userId!)) as Character[]
const characters: Character[] = (await CharacterRepository.getByUserId(user_id)) as Character[]
this.socket.emit('character:list', characters) this.socket.emit('character:list', characters)
} catch (error: any) { } catch (error: any) {

View File

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

View File

@ -25,7 +25,7 @@ export default class AlertCommandEvent {
} }
// Check if character exists // Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number) const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) { if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found') gameLogger.error('chat:alert_command error', 'Character not found')
callback(false) callback(false)

View File

@ -26,7 +26,7 @@ export default class SetTimeCommand {
} }
// Check if character exists // Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number) const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) { if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found') gameLogger.error('chat:alert_command error', 'Character not found')
callback(false) callback(false)

View File

@ -1,10 +1,10 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { ExtendedCharacter, TSocket } from '../../../utilities/types' import { TSocket } from '../../../utilities/types'
import { getArgs, isCommand } from '../../../utilities/chat' import { getArgs, isCommand } from '../../../utilities/chat'
import ZoneRepository from '../../../repositories/zoneRepository' import ZoneRepository from '../../../repositories/zoneRepository'
import CharacterManager from '../../../managers/characterManager'
import { gameLogger, gameMasterLogger } from '../../../utilities/logger' import { gameLogger, gameMasterLogger } from '../../../utilities/logger'
import CharacterRepository from '../../../repositories/characterRepository' import ZoneManager from '../../../managers/zoneManager'
import ZoneCharacter from '../../../models/zoneCharacter'
type TypePayload = { type TypePayload = {
message: string message: string
@ -23,13 +23,15 @@ export default class TeleportCommandEvent {
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 // Check if character exists
const character = (await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number)) as ExtendedCharacter const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter) {
gameLogger.error('chat:alert_command error', 'Character not found') gameLogger.error('chat:send_message error', 'Character not found')
callback(false) callback(false)
return return
} }
const character = zoneCharacter.character
// Check if the user is the GM // Check if the user is the GM
if (character.role !== 'gm') { if (character.role !== 'gm') {
gameLogger.info(`User ${character.id} tried to set time but is not a game master.`) gameLogger.info(`User ${character.id} tried to set time but is not a game master.`)
@ -75,11 +77,11 @@ export default class TeleportCommandEvent {
character.positionX = 0 character.positionX = 0
character.positionY = 0 character.positionY = 0
character.resetMovement = true zoneCharacter.isMoving = false
this.socket.emit('zone:character:teleport', { this.socket.emit('zone:character:teleport', {
zone, zone,
characters: CharacterManager.getCharactersInZone(zone) characters: ZoneManager.getZoneById(zone.id)?.getCharactersInZone()
}) })
this.socket.emit('notification', { title: 'Server message', message: `You have been teleported to ${zone.name}` }) this.socket.emit('notification', { title: 'Server message', message: `You have been teleported to ${zone.name}` })

View File

@ -26,7 +26,7 @@ export default class ToggleFogCommand {
} }
// Check if character exists // Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number) const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) { if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found') gameLogger.error('chat:alert_command error', 'Character not found')
callback(false) callback(false)

View File

@ -26,7 +26,7 @@ export default class ToggleRainCommand {
} }
// Check if character exists // Check if character exists
const character = await CharacterRepository.getByUserAndId(this.socket.user?.id as number, this.socket.characterId as number) const character = await CharacterRepository.getByUserAndId(this.socket.userId!, this.socket.characterId!)
if (!character) { if (!character) {
gameLogger.error('chat:alert_command error', 'Character not found') gameLogger.error('chat:alert_command error', 'Character not found')
callback(false) callback(false)

View File

@ -3,7 +3,7 @@ import { TSocket } from '../../utilities/types'
import ZoneRepository from '../../repositories/zoneRepository' import ZoneRepository from '../../repositories/zoneRepository'
import { isCommand } from '../../utilities/chat' import { isCommand } from '../../utilities/chat'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import CharacterManager from '../../managers/characterManager' import ZoneManager from '../../managers/zoneManager'
type TypePayload = { type TypePayload = {
message: string message: string
@ -26,13 +26,15 @@ export default class ChatMessageEvent {
return return
} }
const character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter) {
gameLogger.error('chat:send_message error', 'Character not found') gameLogger.error('chat:send_message error', 'Character not found')
callback(false) callback(false)
return return
} }
const character = zoneCharacter.character
const zone = await ZoneRepository.getById(character.zoneId) const zone = await ZoneRepository.getById(character.zoneId)
if (!zone) { if (!zone) {
gameLogger.error('chat:send_message error', 'Zone not found') gameLogger.error('chat:send_message error', 'Zone not found')

View File

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

View File

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

View File

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

View File

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

View File

@ -4,8 +4,8 @@ import prisma from '../../../../utilities/prisma'
import type { Prisma, SpriteAction } from '@prisma/client' import type { Prisma, SpriteAction } from '@prisma/client'
import { writeFile, mkdir } from 'node:fs/promises' import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp' import sharp from 'sharp'
import CharacterManager from '../../../../managers/characterManager'
import { getPublicPath } from '../../../../utilities/storage' import { getPublicPath } from '../../../../utilities/storage'
import CharacterRepository from '../../../../repositories/characterRepository'
type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & { type SpriteActionInput = Omit<SpriteAction, 'id' | 'spriteId' | 'frameWidth' | 'frameHeight'> & {
sprites: string[] sprites: string[]
@ -38,7 +38,7 @@ export default class SpriteUpdateEvent {
} }
private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> { private async handleSpriteUpdate(data: Payload, callback: (success: boolean) => void): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket) const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') { if (character?.role !== 'gm') {
return callback(false) return callback(false)
} }

View File

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

View File

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

View File

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

View File

@ -1,77 +1,53 @@
import { Server } from 'socket.io' import { Server } from 'socket.io'
import { TSocket, ExtendedCharacter } from '../../utilities/types' import { TSocket, ZoneEventTileWithTeleport } from '../../utilities/types'
import { CharacterMoveService } from '../../services/character/characterMoveService' import { CharacterMoveService } from '../../services/character/characterMoveService'
import { ZoneEventTileService } from '../../services/zoneEventTileService' import { ZoneEventTileService } from '../../services/zoneEventTileService'
import prisma from '../../utilities/prisma' import prisma from '../../utilities/prisma'
import { ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client'
import Rotation from '../../utilities/character/rotation' import Rotation from '../../utilities/character/rotation'
import CharacterManager from '../../managers/characterManager'
import { gameLogger } from '../../utilities/logger' import { gameLogger } from '../../utilities/logger'
import QueueManager from '../../managers/queueManager' import ZoneManager from '../../managers/zoneManager'
import ZoneCharacter from '../../models/zoneCharacter'
export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
}
export default class CharacterMove { export default class CharacterMove {
private characterMoveService: CharacterMoveService private readonly characterMoveService = new CharacterMoveService()
private zoneEventTileService: ZoneEventTileService private readonly zoneEventTileService = new ZoneEventTileService()
private nextPath: { [index: number]: { x: number; y: number }[] } = [] private nextPath = new Map<number, { x: number; y: number }[]>()
private currentZoneId: { [index: number]: number } = []
constructor( constructor(
private readonly io: Server, private readonly io: Server,
private readonly socket: TSocket private readonly socket: TSocket
) { ) {}
this.characterMoveService = new CharacterMoveService()
this.zoneEventTileService = new ZoneEventTileService()
}
public listen(): void { public listen(): void {
this.socket.on('character:initMove', this.handleCharacterMove.bind(this)) this.socket.on('character:move', this.handleCharacterMove.bind(this))
} }
private async handleCharacterMove({ positionX, positionY }: { positionX: number; positionY: number }): Promise<void> { private async handleCharacterMove({ positionX, positionY }: { positionX: number; positionY: number }): Promise<void> {
let character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter?.character) {
gameLogger.error('character:move error', 'Character not found') gameLogger.error('character:move error', 'Character not found or not initialized')
return return
} }
if (!character) { const path = await this.characterMoveService.calculatePath(zoneCharacter.character, positionX, positionY)
gameLogger.error('character:move error', 'character has not been initialized?')
return
}
const path = await this.characterMoveService.calculatePath(character, positionX, positionY)
if (!path) { if (!path) {
this.io.in(character.zoneId.toString()).emit('character:moveError', 'No valid path found') this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:moveError', 'No valid path found')
return return
} }
if (!character.isMoving && character.resetMovement) { if (!zoneCharacter.isMoving) {
character.resetMovement = false zoneCharacter.isMoving = true
} await this.moveAlongPath(zoneCharacter, path)
if (character.isMoving && !character.resetMovement) { } else {
character.resetMovement = true this.nextPath.set(zoneCharacter.character.id, path)
this.nextPath[character.id] = path
}
if (!character.isMoving && !character.resetMovement) {
character.isMoving = true
this.currentZoneId[character.id] = character.zoneId
await this.moveAlongPath(character, path)
} }
} }
private async moveAlongPath(character: ExtendedCharacter, path: Array<{ x: number; y: number }>): Promise<void> { private async moveAlongPath(zoneCharacter: ZoneCharacter, path: Array<{ x: number; y: number }>): Promise<void> {
const { character } = zoneCharacter
for (let i = 0; i < path.length - 1; i++) { for (let i = 0; i < path.length - 1; i++) {
const start = path[i] const [start, end] = [path[i], path[i + 1]]
const end = path[i + 1]
if (CharacterManager.hasResetMovement(character)) {
break
}
character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y) character.rotation = Rotation.calculate(start.x, start.y, end.x, end.y)
const zoneEventTile = await prisma.zoneEventTile.findFirst({ const zoneEventTile = await prisma.zoneEventTile.findFirst({
@ -79,62 +55,44 @@ export default class CharacterMove {
zoneId: character.zoneId, zoneId: character.zoneId,
positionX: Math.floor(end.x), positionX: Math.floor(end.x),
positionY: Math.floor(end.y) positionY: Math.floor(end.y)
} },
include: { teleport: true }
}) })
if (zoneEventTile) { if (zoneEventTile?.type === 'BLOCK') break
if (zoneEventTile.type === 'BLOCK') { if (zoneEventTile?.type === 'TELEPORT' && zoneEventTile.teleport) {
await this.handleZoneEventTile(zoneEventTile as ZoneEventTileWithTeleport)
break 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.characterMoveService.updatePosition(character, end)
this.io.in(character.zoneId.toString()).emit('character:move', character) this.io.in(character.zoneId.toString()).emit('character:move', zoneCharacter)
await this.characterMoveService.applyMovementDelay() await this.characterMoveService.applyMovementDelay()
} }
if (CharacterManager.hasResetMovement(character)) { const nextPath = this.nextPath.get(character.id)
character.resetMovement = false if (nextPath) {
if (this.currentZoneId[character.id] === character.zoneId) { this.nextPath.delete(character.id)
await this.moveAlongPath(character, this.nextPath[character.id]) await this.moveAlongPath(zoneCharacter, nextPath)
} else { } else {
delete this.currentZoneId[character.id] this.finalizeMovement(zoneCharacter)
character.isMoving = false
}
} else {
this.finalizeMovement(character)
} }
} }
private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> { private async handleZoneEventTile(zoneEventTile: ZoneEventTileWithTeleport): Promise<void> {
const character = CharacterManager.getCharacterFromSocket(this.socket) const zoneCharacter = ZoneManager.getCharacter(this.socket.characterId!)
if (!character) { if (!zoneCharacter) {
gameLogger.error('character:move error', 'Character not found') gameLogger.error('character:move error', 'Character not found')
return return
} }
const teleport = zoneEventTile.teleport if (zoneEventTile.teleport) {
if (teleport) { await this.zoneEventTileService.handleTeleport(this.io, this.socket, zoneCharacter.character, zoneEventTile.teleport)
await this.zoneEventTileService.handleTeleport(this.io, this.socket, character, teleport)
return
} }
} }
private finalizeMovement(character: ExtendedCharacter): void { private finalizeMovement(zoneCharacter: ZoneCharacter): void {
character.isMoving = false zoneCharacter.isMoving = false
this.io.in(character.zoneId.toString()).emit('character:move', character) this.io.in(zoneCharacter.character.zoneId.toString()).emit('character:move', zoneCharacter)
} }
} }

View File

@ -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 { gameLogger } from '../utilities/logger' import { gameLogger } from '../../utilities/logger'
import WeatherManager from '../managers/weatherManager' import WeatherManager from '../../managers/weatherManager'
export default class Weather { export default class Weather {
constructor( constructor(

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import { Socket } from 'socket.io' import { Socket } from 'socket.io'
import { Character, User } from '@prisma/client' import { Character, User, ZoneEventTile, ZoneEventTileTeleport } from '@prisma/client'
export type TSocket = Socket & { export type TSocket = Socket & {
user?: User userId?: number
characterId?: number characterId?: number
handshake?: { handshake?: {
query?: { query?: {
@ -18,7 +18,11 @@ export type TSocket = Socket & {
export type ExtendedCharacter = Character & { export type ExtendedCharacter = Character & {
isMoving?: boolean isMoving?: boolean
resetMovement: boolean resetMovement?: boolean
}
export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
} }
export type AssetData = { export type AssetData = {