1
0
forked from noxious/server

Compare commits

...

32 Commits

Author SHA1 Message Date
0c155347c4 Merge remote-tracking branch 'origin/main' into feature/#263
# Conflicts:
#	src/events/zone/characterMove.ts
2025-01-03 17:30:40 +01:00
149485634d seggs 2025-01-03 17:10:52 +01:00
bc67db7db7 Readme imporvement 2025-01-03 16:08:19 +01:00
a40b71140a Almost finalised refactoring 2025-01-03 14:35:02 +01:00
9a016a1fb6 Re added the movement interruption code to allow overwriting movement
Still needs tinkering
2025-01-02 23:47:55 +01:00
ce80eb223c #263 - Updated character movement to ts (WIP) 2025-01-02 23:36:56 +01:00
fecdf222d7 Forgot 2025-01-02 18:14:12 +01:00
11041fec83 Renamed zone > map 2025-01-02 17:31:24 +01:00
887da447e0 Updated package.json 2025-01-02 02:27:42 +01:00
347554998a Small consistency changes 2025-01-02 02:25:32 +01:00
f7dbf09bf5 More event progress 2025-01-02 02:24:09 +01:00
ab89d0cbb0 Tile CRUD works again 2025-01-02 01:38:52 +01:00
664a74c973 Characters bug fix 2025-01-01 23:52:48 +01:00
0c77758351 More small improvements 2025-01-01 23:01:44 +01:00
e8ef160f2a Minor improvements 2025-01-01 22:50:58 +01:00
2d6831b4ef Improved readability of weather and date managers 2025-01-01 22:09:44 +01:00
0464538b1c N/A 2025-01-01 21:57:47 +01:00
e61aeed691 Refactor send chat logic 2025-01-01 21:57:24 +01:00
45e756fcd3 Fixed left-overs from #293 2025-01-01 21:49:01 +01:00
7c473de12b number>uuid 2025-01-01 21:37:02 +01:00
5982422e04 Storage class is now OOP 2025-01-01 21:34:23 +01:00
04e081c31a Small improvement 2025-01-01 21:19:35 +01:00
599264b362 TS fix 2025-01-01 21:03:42 +01:00
6f84238503 Minor TS improvement 2025-01-01 20:59:30 +01:00
586bb0ca83 #293: Changed IDs to UUIDs for all entities 2025-01-01 20:53:05 +01:00
465219276d Added update & delete rules to entities 2025-01-01 20:44:36 +01:00
11d30351ba Bug fix for loading sprite actions 2025-01-01 20:44:16 +01:00
85af73c079 renamed id to characterId 2025-01-01 17:49:10 +01:00
9c28b10383 format & lint 2025-01-01 04:48:30 +01:00
495e9f192e Joining, leaving rooms and teleporting works again + refactor 2025-01-01 04:46:00 +01:00
30b2028bd8 Fix for creating new characters, added teleport function to zone character model 2025-01-01 03:00:03 +01:00
9d6a8730a9 Added socket helper functions 2024-12-31 15:27:31 +01:00
114 changed files with 1937 additions and 2116 deletions

View File

@ -33,8 +33,12 @@ Run `npx mikro-orm migration:create --initial` to create a new initial migration
### Create migrations
Run `npx mikro-orm migration:create` to create a new migration.
Run `npx mikro-orm migration:create` to create a new migration. You do this when you want to add a new table or change an existing one.
### Apply migrations
Run `npx mikro-orm migration:up` to apply all pending migrations.
Run `npx mikro-orm migration:up` to apply all pending migrations.
### Import default data
After running the server, write `init` in the console to import default data.

View File

@ -1,104 +0,0 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20241229234130 extends Migration {
override async up(): Promise<void> {
this.addSql(`create table \`map_object\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`tags\` json null, \`origin_x\` int not null default 0, \`origin_y\` int not null default 0, \`is_animated\` tinyint(1) not null default false, \`frame_rate\` int not null default 0, \`frame_width\` int not null default 0, \`frame_height\` int not null default 0, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`sprite\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`item\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`description\` varchar(255) null, \`item_type\` enum('WEAPON', 'HELMET', 'CHEST', 'LEGS', 'BOOTS', 'GLOVES', 'RING', 'NECKLACE') not null, \`stackable\` tinyint(1) not null default false, \`rarity\` enum('COMMON', 'UNCOMMON', 'RARE', 'EPIC', 'LEGENDARY') not null default 'COMMON', \`sprite_id\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`item\` add index \`item_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`character_type\` (\`id\` int unsigned not null auto_increment primary key, \`name\` varchar(255) not null, \`gender\` enum('MALE', 'FEMALE') not null, \`race\` enum('HUMAN', 'ELF', 'DWARF', 'ORC', 'GOBLIN') not null, \`is_selectable\` tinyint(1) not null default false, \`sprite_id\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_type\` add index \`character_type_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`character_hair\` (\`id\` int unsigned not null auto_increment primary key, \`name\` varchar(255) not null, \`gender\` varchar(255) not null default 'MALE', \`is_selectable\` tinyint(1) not null default false, \`sprite_id\` varchar(255) null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_hair\` add index \`character_hair_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`sprite_action\` (\`id\` varchar(255) not null, \`sprite_id\` varchar(255) not null, \`action\` varchar(255) not null, \`sprites\` json null, \`origin_x\` int not null default 0, \`origin_y\` int not null default 0, \`is_animated\` tinyint(1) not null default false, \`is_looping\` tinyint(1) not null default false, \`frame_width\` int not null default 0, \`frame_height\` int not null default 0, \`frame_rate\` int not null default 0, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`sprite_action\` add index \`sprite_action_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`tile\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`tags\` json null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`user\` (\`id\` int unsigned not null auto_increment primary key, \`username\` varchar(255) not null, \`email\` varchar(255) not null, \`password\` varchar(255) not null, \`online\` tinyint(1) not null default false) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`user\` add unique \`user_username_unique\`(\`username\`);`);
this.addSql(`alter table \`user\` add unique \`user_email_unique\`(\`email\`);`);
this.addSql(`create table \`password_reset_token\` (\`id\` int unsigned not null auto_increment primary key, \`user_id\` int unsigned not null, \`token\` varchar(255) not null, \`created_at\` datetime not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`password_reset_token\` add index \`password_reset_token_user_id_index\`(\`user_id\`);`);
this.addSql(`alter table \`password_reset_token\` add unique \`password_reset_token_token_unique\`(\`token\`);`);
this.addSql(`create table \`world\` (\`date\` datetime not null, \`is_rain_enabled\` tinyint(1) not null default false, \`rain_percentage\` int not null default 0, \`is_fog_enabled\` tinyint(1) not null default false, \`fog_density\` int not null default 0, primary key (\`date\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`zone\` (\`id\` int unsigned not null auto_increment primary key, \`name\` varchar(255) not null, \`width\` int not null default 10, \`height\` int not null default 10, \`tiles\` json null, \`pvp\` tinyint(1) not null default false, \`created_at\` datetime not null, \`updated_at\` datetime not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`character\` (\`id\` int unsigned not null auto_increment primary key, \`user_id\` int unsigned not null, \`name\` varchar(255) not null, \`online\` tinyint(1) not null default false, \`role\` varchar(255) not null default 'player', \`zone_id\` int unsigned not null, \`position_x\` int not null default 0, \`position_y\` int not null default 0, \`rotation\` int not null default 0, \`character_type_id\` int unsigned null, \`character_hair_id\` int unsigned null, \`alignment\` int not null default 50, \`hitpoints\` int not null default 100, \`mana\` int not null default 100, \`level\` int not null default 1, \`experience\` int not null default 0, \`strength\` int not null default 10, \`dexterity\` int not null default 10, \`intelligence\` int not null default 10, \`wisdom\` int not null default 10) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character\` add index \`character_user_id_index\`(\`user_id\`);`);
this.addSql(`alter table \`character\` add unique \`character_name_unique\`(\`name\`);`);
this.addSql(`alter table \`character\` add index \`character_zone_id_index\`(\`zone_id\`);`);
this.addSql(`alter table \`character\` add index \`character_character_type_id_index\`(\`character_type_id\`);`);
this.addSql(`alter table \`character\` add index \`character_character_hair_id_index\`(\`character_hair_id\`);`);
this.addSql(`create table \`chat\` (\`id\` int unsigned not null auto_increment primary key, \`character_id\` int unsigned not null, \`zone_id\` int unsigned not null, \`message\` varchar(255) not null, \`created_at\` datetime not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`chat\` add index \`chat_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`chat\` add index \`chat_zone_id_index\`(\`zone_id\`);`);
this.addSql(`create table \`character_item\` (\`id\` int unsigned not null auto_increment primary key, \`character_id\` int unsigned not null, \`item_id\` varchar(255) not null, \`quantity\` int not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_item\` add index \`character_item_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`character_item\` add index \`character_item_item_id_index\`(\`item_id\`);`);
this.addSql(`create table \`character_equipment\` (\`id\` int unsigned not null auto_increment primary key, \`slot\` enum('HEAD', 'BODY', 'ARMS', 'LEGS', 'NECK', 'RING') not null, \`character_id\` int unsigned not null, \`character_item_id\` int unsigned not null) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_equipment\` add index \`character_equipment_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`character_equipment\` add index \`character_equipment_character_item_id_index\`(\`character_item_id\`);`);
this.addSql(`create table \`zone_effect\` (\`id\` varchar(255) not null, \`zone_id\` int unsigned not null, \`effect\` varchar(255) not null, \`strength\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`zone_effect\` add index \`zone_effect_zone_id_index\`(\`zone_id\`);`);
this.addSql(`create table \`zone_event_tile\` (\`id\` varchar(255) not null, \`zone_id\` int unsigned not null, \`type\` enum('BLOCK', 'TELEPORT', 'NPC', 'ITEM') not null, \`position_x\` int not null, \`position_y\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`zone_event_tile\` add index \`zone_event_tile_zone_id_index\`(\`zone_id\`);`);
this.addSql(`create table \`zone_event_tile_teleport\` (\`id\` varchar(255) not null, \`zone_event_tile_id\` varchar(255) not null, \`to_zone_id\` int unsigned not null, \`to_rotation\` int not null, \`to_position_x\` int not null, \`to_position_y\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`zone_event_tile_teleport\` add unique \`zone_event_tile_teleport_zone_event_tile_id_unique\`(\`zone_event_tile_id\`);`);
this.addSql(`alter table \`zone_event_tile_teleport\` add index \`zone_event_tile_teleport_to_zone_id_index\`(\`to_zone_id\`);`);
this.addSql(`create table \`zone_object\` (\`id\` varchar(255) not null, \`zone_id\` int unsigned not null, \`map_object_id\` varchar(255) not null, \`depth\` int not null default 0, \`is_rotated\` tinyint(1) not null default false, \`position_x\` int not null default 0, \`position_y\` int not null default 0, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`zone_object\` add index \`zone_object_zone_id_index\`(\`zone_id\`);`);
this.addSql(`alter table \`zone_object\` add index \`zone_object_map_object_id_index\`(\`map_object_id\`);`);
this.addSql(`alter table \`item\` add constraint \`item_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character_type\` add constraint \`character_type_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character_hair\` add constraint \`character_hair_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`sprite_action\` add constraint \`sprite_action_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`password_reset_token\` add constraint \`password_reset_token_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_zone_id_foreign\` foreign key (\`zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_character_type_id_foreign\` foreign key (\`character_type_id\`) references \`character_type\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character\` add constraint \`character_character_hair_id_foreign\` foreign key (\`character_hair_id\`) references \`character_hair\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`chat\` add constraint \`chat_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`chat\` add constraint \`chat_zone_id_foreign\` foreign key (\`zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character_item\` add constraint \`character_item_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character_item\` add constraint \`character_item_item_id_foreign\` foreign key (\`item_id\`) references \`item\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character_equipment\` add constraint \`character_equipment_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character_equipment\` add constraint \`character_equipment_character_item_id_foreign\` foreign key (\`character_item_id\`) references \`character_item\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_effect\` add constraint \`zone_effect_zone_id_foreign\` foreign key (\`zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_event_tile\` add constraint \`zone_event_tile_zone_id_foreign\` foreign key (\`zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_event_tile_teleport\` add constraint \`zone_event_tile_teleport_zone_event_tile_id_foreign\` foreign key (\`zone_event_tile_id\`) references \`zone_event_tile\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_event_tile_teleport\` add constraint \`zone_event_tile_teleport_to_zone_id_foreign\` foreign key (\`to_zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_object\` add constraint \`zone_object_zone_id_foreign\` foreign key (\`zone_id\`) references \`zone\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`zone_object\` add constraint \`zone_object_map_object_id_foreign\` foreign key (\`map_object_id\`) references \`map_object\` (\`id\`) on update cascade;`);
}
}

View File

@ -0,0 +1,104 @@
import { Migration } from '@mikro-orm/migrations';
export class Migration20250103003053 extends Migration {
override async up(): Promise<void> {
this.addSql(`create table \`map\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`width\` int not null default 10, \`height\` int not null default 10, \`tiles\` json null, \`pvp\` tinyint(1) not null default false, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`map_effect\` (\`id\` varchar(255) not null, \`map_id\` varchar(255) not null, \`effect\` varchar(255) not null, \`strength\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`map_effect\` add index \`map_effect_map_id_index\`(\`map_id\`);`);
this.addSql(`create table \`map_event_tile\` (\`id\` varchar(255) not null, \`map_id\` varchar(255) not null, \`type\` enum('BLOCK', 'TELEPORT', 'NPC', 'ITEM') not null, \`position_x\` int not null, \`position_y\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`map_event_tile\` add index \`map_event_tile_map_id_index\`(\`map_id\`);`);
this.addSql(`create table \`map_event_tile_teleport\` (\`id\` varchar(255) not null, \`map_event_tile_id\` varchar(255) not null, \`to_map_id\` varchar(255) not null, \`to_rotation\` int not null, \`to_position_x\` int not null, \`to_position_y\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`map_event_tile_teleport\` add unique \`map_event_tile_teleport_map_event_tile_id_unique\`(\`map_event_tile_id\`);`);
this.addSql(`alter table \`map_event_tile_teleport\` add index \`map_event_tile_teleport_to_map_id_index\`(\`to_map_id\`);`);
this.addSql(`create table \`map_object\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`tags\` json null, \`origin_x\` numeric(10,2) not null default 0, \`origin_y\` numeric(10,2) not null default 0, \`is_animated\` tinyint(1) not null default false, \`frame_rate\` int not null default 0, \`frame_width\` int not null default 0, \`frame_height\` int not null default 0, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`placed_map_object\` (\`id\` varchar(255) not null, \`map_id\` varchar(255) not null, \`map_object_id\` varchar(255) not null, \`depth\` int not null default 0, \`is_rotated\` tinyint(1) not null default false, \`position_x\` int not null default 0, \`position_y\` int not null default 0, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`placed_map_object\` add index \`placed_map_object_map_id_index\`(\`map_id\`);`);
this.addSql(`alter table \`placed_map_object\` add index \`placed_map_object_map_object_id_index\`(\`map_object_id\`);`);
this.addSql(`create table \`sprite\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`item\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`description\` varchar(255) null, \`item_type\` enum('WEAPON', 'HELMET', 'CHEST', 'LEGS', 'BOOTS', 'GLOVES', 'RING', 'NECKLACE') not null, \`stackable\` tinyint(1) not null default false, \`rarity\` enum('COMMON', 'UNCOMMON', 'RARE', 'EPIC', 'LEGENDARY') not null default 'COMMON', \`sprite_id\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`item\` add index \`item_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`character_type\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`gender\` enum('MALE', 'FEMALE') not null, \`race\` enum('HUMAN', 'ELF', 'DWARF', 'ORC', 'GOBLIN') not null, \`is_selectable\` tinyint(1) not null default false, \`sprite_id\` varchar(255) null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_type\` add index \`character_type_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`character_hair\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`gender\` varchar(255) not null default 'MALE', \`is_selectable\` tinyint(1) not null default false, \`sprite_id\` varchar(255) null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_hair\` add index \`character_hair_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`sprite_action\` (\`id\` varchar(255) not null, \`sprite_id\` varchar(255) not null, \`action\` varchar(255) not null, \`sprites\` json null, \`origin_x\` int not null default 0, \`origin_y\` int not null default 0, \`is_animated\` tinyint(1) not null default false, \`is_looping\` tinyint(1) not null default false, \`frame_width\` int not null default 0, \`frame_height\` int not null default 0, \`frame_rate\` int not null default 0, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`sprite_action\` add index \`sprite_action_sprite_id_index\`(\`sprite_id\`);`);
this.addSql(`create table \`tile\` (\`id\` varchar(255) not null, \`name\` varchar(255) not null, \`tags\` json null, \`created_at\` datetime not null, \`updated_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`create table \`user\` (\`id\` varchar(255) not null, \`username\` varchar(255) not null, \`email\` varchar(255) not null, \`password\` varchar(255) not null, \`online\` tinyint(1) not null default false, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`user\` add unique \`user_username_unique\`(\`username\`);`);
this.addSql(`alter table \`user\` add unique \`user_email_unique\`(\`email\`);`);
this.addSql(`create table \`password_reset_token\` (\`id\` varchar(255) not null, \`user_id\` varchar(255) not null, \`token\` varchar(255) not null, \`created_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`password_reset_token\` add index \`password_reset_token_user_id_index\`(\`user_id\`);`);
this.addSql(`alter table \`password_reset_token\` add unique \`password_reset_token_token_unique\`(\`token\`);`);
this.addSql(`create table \`character\` (\`id\` varchar(255) not null, \`user_id\` varchar(255) not null, \`name\` varchar(255) not null, \`online\` tinyint(1) not null default false, \`role\` varchar(255) not null default 'player', \`map_id\` varchar(255) not null, \`position_x\` int not null default 0, \`position_y\` int not null default 0, \`rotation\` int not null default 0, \`character_type_id\` varchar(255) null, \`character_hair_id\` varchar(255) null, \`alignment\` int not null default 50, \`hitpoints\` int not null default 100, \`mana\` int not null default 100, \`level\` int not null default 1, \`experience\` int not null default 0, \`strength\` int not null default 10, \`dexterity\` int not null default 10, \`intelligence\` int not null default 10, \`wisdom\` int not null default 10, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character\` add index \`character_user_id_index\`(\`user_id\`);`);
this.addSql(`alter table \`character\` add unique \`character_name_unique\`(\`name\`);`);
this.addSql(`alter table \`character\` add index \`character_map_id_index\`(\`map_id\`);`);
this.addSql(`alter table \`character\` add index \`character_character_type_id_index\`(\`character_type_id\`);`);
this.addSql(`alter table \`character\` add index \`character_character_hair_id_index\`(\`character_hair_id\`);`);
this.addSql(`create table \`chat\` (\`id\` varchar(255) not null, \`character_id\` varchar(255) not null, \`map_id\` varchar(255) not null, \`message\` varchar(255) not null, \`created_at\` datetime not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`chat\` add index \`chat_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`chat\` add index \`chat_map_id_index\`(\`map_id\`);`);
this.addSql(`create table \`character_item\` (\`id\` varchar(255) not null, \`character_id\` varchar(255) not null, \`item_id\` varchar(255) not null, \`quantity\` int not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_item\` add index \`character_item_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`character_item\` add index \`character_item_item_id_index\`(\`item_id\`);`);
this.addSql(`create table \`character_equipment\` (\`id\` varchar(255) not null, \`slot\` enum('HEAD', 'BODY', 'ARMS', 'LEGS', 'NECK', 'RING') not null, \`character_id\` varchar(255) not null, \`character_item_id\` varchar(255) not null, primary key (\`id\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`character_equipment\` add index \`character_equipment_character_id_index\`(\`character_id\`);`);
this.addSql(`alter table \`character_equipment\` add index \`character_equipment_character_item_id_index\`(\`character_item_id\`);`);
this.addSql(`create table \`world\` (\`date\` datetime not null, \`is_rain_enabled\` tinyint(1) not null default false, \`rain_percentage\` int not null default 0, \`is_fog_enabled\` tinyint(1) not null default false, \`fog_density\` int not null default 0, primary key (\`date\`)) default character set utf8mb4 engine = InnoDB;`);
this.addSql(`alter table \`map_effect\` add constraint \`map_effect_map_id_foreign\` foreign key (\`map_id\`) references \`map\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`map_event_tile\` add constraint \`map_event_tile_map_id_foreign\` foreign key (\`map_id\`) references \`map\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`map_event_tile_teleport\` add constraint \`map_event_tile_teleport_map_event_tile_id_foreign\` foreign key (\`map_event_tile_id\`) references \`map_event_tile\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`map_event_tile_teleport\` add constraint \`map_event_tile_teleport_to_map_id_foreign\` foreign key (\`to_map_id\`) references \`map\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`placed_map_object\` add constraint \`placed_map_object_map_id_foreign\` foreign key (\`map_id\`) references \`map\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`placed_map_object\` add constraint \`placed_map_object_map_object_id_foreign\` foreign key (\`map_object_id\`) references \`map_object\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`item\` add constraint \`item_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character_type\` add constraint \`character_type_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character_hair\` add constraint \`character_hair_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`sprite_action\` add constraint \`sprite_action_sprite_id_foreign\` foreign key (\`sprite_id\`) references \`sprite\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`password_reset_token\` add constraint \`password_reset_token_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_user_id_foreign\` foreign key (\`user_id\`) references \`user\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_map_id_foreign\` foreign key (\`map_id\`) references \`map\` (\`id\`) on update cascade;`);
this.addSql(`alter table \`character\` add constraint \`character_character_type_id_foreign\` foreign key (\`character_type_id\`) references \`character_type\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`character\` add constraint \`character_character_hair_id_foreign\` foreign key (\`character_hair_id\`) references \`character_hair\` (\`id\`) on update cascade on delete set null;`);
this.addSql(`alter table \`chat\` add constraint \`chat_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`chat\` add constraint \`chat_map_id_foreign\` foreign key (\`map_id\`) references \`map\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character_item\` add constraint \`character_item_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character_item\` add constraint \`character_item_item_id_foreign\` foreign key (\`item_id\`) references \`item\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character_equipment\` add constraint \`character_equipment_character_id_foreign\` foreign key (\`character_id\`) references \`character\` (\`id\`) on update cascade on delete cascade;`);
this.addSql(`alter table \`character_equipment\` add constraint \`character_equipment_character_item_id_foreign\` foreign key (\`character_item_id\`) references \`character_item\` (\`id\`) on update cascade on delete cascade;`);
}
}

View File

@ -17,6 +17,7 @@ export default defineConfig({
password: serverConfig.DB_PASS,
dbName: serverConfig.DB_NAME,
debug: serverConfig.ENV !== 'production',
// allowGlobalContext: true,
driverOptions: {
allowPublicKeyRetrieval: true
},

121
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "nq-server",
"name": "noxious-server",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -1784,9 +1784,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.17.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.10.tgz",
"integrity": "sha512-/jrvh5h6NXhEauFFexRin69nA0uHJ5gwk4iDivp/DeoEua3uwCUto6PC86IpRITBOs4+6i2I56K5x5b6WYGXHA==",
"version": "20.17.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.11.tgz",
"integrity": "sha512-Ept5glCK35R8yeyIeYlRIZtX6SLRyqMhOFTgj5SOkMpLTdw3SEHI9fHx60xaUZ+V1aJxQJODE+7/j5ocZydYTg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.2"
@ -1840,17 +1840,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz",
"integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.0.tgz",
"integrity": "sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.18.2",
"@typescript-eslint/type-utils": "8.18.2",
"@typescript-eslint/utils": "8.18.2",
"@typescript-eslint/visitor-keys": "8.18.2",
"@typescript-eslint/scope-manager": "8.19.0",
"@typescript-eslint/type-utils": "8.19.0",
"@typescript-eslint/utils": "8.19.0",
"@typescript-eslint/visitor-keys": "8.19.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@ -1870,16 +1870,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz",
"integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.19.0.tgz",
"integrity": "sha512-6M8taKyOETY1TKHp0x8ndycipTVgmp4xtg5QpEZzXxDhNvvHOJi5rLRkLr8SK3jTgD5l4fTlvBiRdfsuWydxBw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.18.2",
"@typescript-eslint/types": "8.18.2",
"@typescript-eslint/typescript-estree": "8.18.2",
"@typescript-eslint/visitor-keys": "8.18.2",
"@typescript-eslint/scope-manager": "8.19.0",
"@typescript-eslint/types": "8.19.0",
"@typescript-eslint/typescript-estree": "8.19.0",
"@typescript-eslint/visitor-keys": "8.19.0",
"debug": "^4.3.4"
},
"engines": {
@ -1895,14 +1895,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz",
"integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.19.0.tgz",
"integrity": "sha512-hkoJiKQS3GQ13TSMEiuNmSCvhz7ujyqD1x3ShbaETATHrck+9RaDdUbt+osXaUuns9OFwrDTTrjtwsU8gJyyRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.2",
"@typescript-eslint/visitor-keys": "8.18.2"
"@typescript-eslint/types": "8.19.0",
"@typescript-eslint/visitor-keys": "8.19.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1913,14 +1913,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz",
"integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.19.0.tgz",
"integrity": "sha512-TZs0I0OSbd5Aza4qAMpp1cdCYVnER94IziudE3JU328YUHgWu9gwiwhag+fuLeJ2LkWLXI+F/182TbG+JaBdTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.18.2",
"@typescript-eslint/utils": "8.18.2",
"@typescript-eslint/typescript-estree": "8.19.0",
"@typescript-eslint/utils": "8.19.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@ -1937,9 +1937,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz",
"integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.19.0.tgz",
"integrity": "sha512-8XQ4Ss7G9WX8oaYvD4OOLCjIQYgRQxO+qCiR2V2s2GxI9AUpo7riNwo6jDhKtTcaJjT8PY54j2Yb33kWtSJsmA==",
"dev": true,
"license": "MIT",
"engines": {
@ -1951,14 +1951,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz",
"integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.19.0.tgz",
"integrity": "sha512-WW9PpDaLIFW9LCbucMSdYUuGeFUz1OkWYS/5fwZwTA+l2RwlWFdJvReQqMUMBw4yJWJOfqd7An9uwut2Oj8sLw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.2",
"@typescript-eslint/visitor-keys": "8.18.2",
"@typescript-eslint/types": "8.19.0",
"@typescript-eslint/visitor-keys": "8.19.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -1978,16 +1978,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz",
"integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.19.0.tgz",
"integrity": "sha512-PTBG+0oEMPH9jCZlfg07LCB2nYI0I317yyvXGfxnvGvw4SHIOuRnQ3kadyyXY6tGdChusIHIbM5zfIbp4M6tCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.18.2",
"@typescript-eslint/types": "8.18.2",
"@typescript-eslint/typescript-estree": "8.18.2"
"@typescript-eslint/scope-manager": "8.19.0",
"@typescript-eslint/types": "8.19.0",
"@typescript-eslint/typescript-estree": "8.19.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -2002,13 +2002,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz",
"integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==",
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.19.0.tgz",
"integrity": "sha512-mCFtBbFBJDCNCWUl5y6sZSCHXw1DEFEk3c/M3nRK2a4XUB8StGFtmcEMizdjKuBzB6e/smJAAWYug3VrdLMr1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.18.2",
"@typescript-eslint/types": "8.19.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@ -2457,9 +2457,9 @@
"license": "BSD-3-Clause"
},
"node_modules/bullmq": {
"version": "5.34.5",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.34.5.tgz",
"integrity": "sha512-MHho9EOhLCTY3ZF+dd0wHv0VlY2FtpBcopMRsvj0kPra4TAwBFh2pik/s4WbX56cIfCE+VzfHIHy4xvqp3g1+Q==",
"version": "5.34.6",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.34.6.tgz",
"integrity": "sha512-pRCYyO9RlkQWxdmKlrNnUthyFwurYXRYLVXD1YIx+nCCdhAOiHatD8FDHbsT/w2I31c0NWoMcfZiIGuipiF7Lg==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.9.0",
@ -3179,15 +3179,16 @@
}
},
"node_modules/es-set-tostringtag": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz",
"integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-intrinsic": "^1.2.4",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.1"
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
@ -5214,9 +5215,9 @@
}
},
"node_modules/mariadb/node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
"version": "22.10.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.3.tgz",
"integrity": "sha512-DifAyw4BkrufCILvD3ucnuN8eydUfc/C1GlyrnI+LK6543w5/L3VeVgf05o3B4fqSXP1dKYLOZsKfutpxPzZrw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
@ -5732,9 +5733,9 @@
}
},
"node_modules/own-keys": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.0.tgz",
"integrity": "sha512-HcuIjzpjrUbqZPGzWHVg95Bc2Y37KoY5n66QQyEGMzrIWVKHsgHcv8/Aq5Cu3qFUQJzMSPVP8MD3oaFoaME1lg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
"integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -1,9 +1,7 @@
{
"useTsNode": true,
"alwaysAllowTs": true,
"scripts": {
"start": "npx prisma migrate deploy && node dist/server.js",
"dev": "nodemon --ignore 'data/*' --exec tsx src/server.ts",
"start": "node dist/server.js",
"dev": "nodemon --exec tsx src/server.ts",
"build": "tsc",
"format": "prettier --write src/",
"lint": "eslint .",

Binary file not shown.

View File

@ -2,6 +2,8 @@ import { Server } from 'socket.io'
import Logger, { LoggerType } from '#application/logger'
import { TSocket } from '#application/types'
import { Character } from '#entities/character'
import CharacterRepository from '#repositories/characterRepository'
export abstract class BaseEvent {
protected readonly logger = Logger.type(LoggerType.GAME)
@ -11,6 +13,15 @@ export abstract class BaseEvent {
readonly socket: TSocket
) {}
protected async getCharacter(): Promise<Character | null> {
return CharacterRepository.getById(this.socket.characterId!)
}
protected async isCharacterGM(): Promise<boolean> {
const character = await this.getCharacter()
return character?.getRole() === 'gm'
}
protected emitError(message: string): void {
this.socket.emit('notification', { title: 'Server message', message })
this.logger.error('character:connect error', `Player ${this.socket.userId}: ${message}`)

View File

@ -3,7 +3,7 @@ import * as path from 'path'
import { pathToFileURL } from 'url'
import Logger, { LoggerType } from '#application/logger'
import { getAppPath } from '#application/storage'
import Storage from '#application/storage'
import { Command } from '#application/types'
export class CommandRegistry {
@ -15,7 +15,7 @@ export class CommandRegistry {
}
public async loadCommands(): Promise<void> {
const directory = getAppPath('commands')
const directory = Storage.getAppPath('commands')
this.logger.info(`Loading commands from: ${directory}`)
try {
@ -32,7 +32,7 @@ export class CommandRegistry {
private async loadCommandFile(file: fs.Dirent): Promise<void> {
try {
const filePath = getAppPath('commands', file.name)
const filePath = Storage.getAppPath('commands', file.name)
const commandName = path.basename(file.name, path.extname(file.name))
const module = await import(pathToFileURL(filePath).href)

View File

@ -6,13 +6,11 @@ import config from '../../mikro-orm.config'
class Database {
private static orm: MikroORM
private static em: EntityManager
private static logger = Logger.type(LoggerType.APP)
public static async initialize(): Promise<void> {
try {
Database.orm = await MikroORM.init(config)
Database.em = Database.orm.em.fork()
this.logger.info('Database connection initialized')
} catch (error) {
this.logger.error(`MikroORM connection failed: ${error}`)
@ -20,18 +18,8 @@ class Database {
}
}
public static getORM(): MikroORM {
if (!Database.orm) {
throw new Error('Database not initialized. Call Database.initialize() first.')
}
return Database.orm
}
public static getEntityManager(): EntityManager {
if (!Database.em) {
throw new Error('Database not initialized. Call Database.initialize() first.')
}
return Database.em
return Database.orm.em.fork()
}
}

View File

@ -55,7 +55,7 @@ export enum CharacterEquipmentSlotType {
RING = 'RING'
}
export enum ZoneEventTileType {
export enum MapEventTileType {
BLOCK = 'BLOCK',
TELEPORT = 'TELEPORT',
NPC = 'NPC',

View File

@ -1,34 +1,71 @@
import fs from 'fs'
import path from 'path'
import config from './config'
import config from '#application/config'
export function getRootPath(folder: string, ...additionalSegments: string[]) {
return path.join(process.cwd(), folder, ...additionalSegments)
}
class Storage {
private readonly baseDir: string
private readonly rootDir: string
export function getAppPath(folder: string, ...additionalSegments: string[]) {
const baseDir = config.ENV === 'development' ? 'src' : 'dist'
return path.join(process.cwd(), baseDir, folder, ...additionalSegments)
}
constructor() {
this.rootDir = process.cwd()
this.baseDir = config.ENV === 'development' ? 'src' : 'dist'
}
export function getPublicPath(folder: string, ...additionalSegments: string[]) {
return path.join(process.cwd(), 'public', folder, ...additionalSegments)
}
/**
* Gets path relative to project root
*/
public getRootPath(folder: string, ...additionalSegments: string[]): string {
return path.join(this.rootDir, folder, ...additionalSegments)
}
export function doesPathExist(path: string) {
try {
fs.accessSync(path, fs.constants.F_OK)
return true
} catch (e) {
return false
/**
* Gets path relative to app directory (src/dist)
*/
public getAppPath(folder: string, ...additionalSegments: string[]): string {
return path.join(this.rootDir, this.baseDir, folder, ...additionalSegments)
}
/**
* Gets path relative to public directory
*/
public getPublicPath(folder: string, ...additionalSegments: string[]): string {
return path.join(this.rootDir, 'public', folder, ...additionalSegments)
}
/**
* Checks if a path exists
* @throws Error if path is empty or invalid
*/
public doesPathExist(pathToCheck: string): boolean {
if (!pathToCheck) {
throw new Error('Path cannot be empty')
}
try {
fs.accessSync(pathToCheck, fs.constants.F_OK)
return true
} catch (e) {
return false
}
}
/**
* Creates a directory and any necessary parent directories
* @throws Error if directory creation fails
*/
public createDir(dirPath: string): void {
if (!dirPath) {
throw new Error('Directory path cannot be empty')
}
try {
fs.mkdirSync(dirPath, { recursive: true })
} catch (error) {
const typedError = error as Error
throw new Error(`Failed to create directory: ${typedError.message}`)
}
}
}
export function createDir(path: string) {
try {
fs.mkdirSync(path, { recursive: true })
} catch (e) {
console.error(e)
}
}
export default new Storage()

View File

@ -1,14 +1,14 @@
import { Server, Socket } from 'socket.io'
import { Character } from '#entities/character'
import { ZoneEventTile } from '#entities/zoneEventTile'
import { ZoneEventTileTeleport } from '#entities/zoneEventTileTeleport'
import { MapEventTile } from '#entities/mapEventTile'
import { MapEventTileTeleport } from '#entities/mapEventTileTeleport'
export type UUID = `${string}-${string}-${string}-${string}-${string}`
export type TSocket = Socket & {
userId?: number
characterId?: number
userId?: UUID
characterId?: UUID
handshake?: {
query?: {
token?: any
@ -26,8 +26,8 @@ export type ExtendedCharacter = Character & {
resetMovement?: boolean
}
export type ZoneEventTileWithTeleport = ZoneEventTile & {
teleport: ZoneEventTileTeleport
export type MapEventTileWithTeleport = MapEventTile & {
teleport: MapEventTileTeleport
}
export type AssetData = {

View File

@ -1,11 +1,10 @@
import fs from 'fs'
import sharp from 'sharp'
import { Server } from 'socket.io'
import { BaseCommand } from '#application/base/baseCommand'
import { CharacterGender, CharacterRace } from '#application/enums'
import { getPublicPath } from '#application/storage'
import Storage from '#application/storage'
import { UUID } from '#application/types'
import { Character } from '#entities/character'
import { CharacterHair } from '#entities/characterHair'
@ -15,11 +14,11 @@ import { Sprite } from '#entities/sprite'
import { SpriteAction } from '#entities/spriteAction'
import { Tile } from '#entities/tile'
import { User } from '#entities/user'
import { Zone } from '#entities/zone'
import { ZoneEffect } from '#entities/zoneEffect'
import { Map } from '#entities/map'
import { MapEffect } from '#entities/mapEffect'
import CharacterHairRepository from '#repositories/characterHairRepository'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
import ZoneRepository from '#repositories/zoneRepository'
import MapRepository from '#repositories/mapRepository'
// @TODO : Replace this with seeding
// https://mikro-orm.io/docs/seeding
@ -28,13 +27,13 @@ export default class InitCommand extends BaseCommand {
public async execute(): Promise<void> {
// Assets
await this.importTiles()
await this.importObjects()
await this.importMapObjects()
await this.createCharacterType()
await this.createCharacterHair()
// await this.createCharacterEquipment()
// Zone
await this.createZone()
// Map
await this.createMap()
// User
await this.createUser()
@ -44,7 +43,7 @@ export default class InitCommand extends BaseCommand {
}
private async importTiles(): Promise<void> {
for (const tile of fs.readdirSync(getPublicPath('tiles'))) {
for (const tile of fs.readdirSync(Storage.getPublicPath('tiles'))) {
const newTile = new Tile()
newTile.setId(tile.split('.')[0] as UUID).setName('New tile')
@ -52,19 +51,19 @@ export default class InitCommand extends BaseCommand {
}
}
private async importObjects(): Promise<void> {
for (const object of fs.readdirSync(getPublicPath('objects'))) {
private async importMapObjects(): Promise<void> {
for (const mapObject of fs.readdirSync(Storage.getPublicPath('map_objects'))) {
const newMapObject = new MapObject()
newMapObject
.setId(object.split('.')[0] as UUID)
.setName('New object')
.setId(mapObject.split('.')[0] as UUID)
.setName('New map object')
.setFrameWidth(
(await sharp(getPublicPath('objects', object))
(await sharp(Storage.getPublicPath('map_objects', mapObject))
.metadata()
.then((metadata) => metadata.height)) ?? 0
)
.setFrameHeight(
(await sharp(getPublicPath('objects', object))
(await sharp(Storage.getPublicPath('map_objects', mapObject))
.metadata()
.then((metadata) => metadata.width)) ?? 0
)
@ -149,7 +148,7 @@ export default class InitCommand extends BaseCommand {
.save()
const characterType = new CharacterType()
await characterType.setId(1).setName('New character type').setGender(CharacterGender.MALE).setRace(CharacterRace.HUMAN).setIsSelectable(true).setSprite(characterSprite).save()
await characterType.setId('75b70c78-17f0-44c0-a4fa-15043cb95be0').setName('New character type').setGender(CharacterGender.MALE).setRace(CharacterRace.HUMAN).setIsSelectable(true).setSprite(characterSprite).save()
}
private async createCharacterHair(): Promise<void> {
@ -190,7 +189,7 @@ export default class InitCommand extends BaseCommand {
.save()
const characterHair = new CharacterHair()
await characterHair.setId(1).setName('Hair 1').setGender(CharacterGender.MALE).setIsSelectable(true).setSprite(hairSprite).save()
await characterHair.setId('a2471230-d238-4ffb-9eca-9eab869f1b67').setName('Hair 1').setGender(CharacterGender.MALE).setIsSelectable(true).setSprite(hairSprite).save()
}
private async createCharacterEquipment(): Promise<void> {
@ -225,30 +224,30 @@ export default class InitCommand extends BaseCommand {
await equipmentSprite.save()
}
private async createZone(): Promise<void> {
const zone = new Zone()
await zone
.setName('New zone')
private async createMap(): Promise<void> {
const map = new Map()
await map
.setName('New map')
.setWidth(100)
.setHeight(100)
.setTiles(Array.from({ length: 100 }, () => Array.from({ length: 100 }, () => 'a2fd8d6f-5042-437a-9c1e-c66b91ecc35b')))
.save()
const effect = new ZoneEffect()
await effect.setEffect('light').setStrength(100).setZone(zone).save()
const effect = new MapEffect()
await effect.setEffect('light').setStrength(100).setMap(map).save()
}
private async createUser(): Promise<void> {
const user = new User()
await user.setId(1).setUsername('root').setEmail('local@host').setPassword('password').setOnline(false).save()
await user.setId('6f9a58b4-172d-425e-b9ea-71e1d13d81ee').setUsername('root').setEmail('local@host').setPassword('password').setOnline(false).save()
const character = new Character()
await character
.setId(1)
.setId('26850183-1757-4135-938f-aa1448c49654')
.setUser(user)
.setName('root')
.setRole('gm')
.setZone((await ZoneRepository.getFirst()) ?? undefined)
.setMap((await MapRepository.getFirst())!)
.setCharacterType((await CharacterTypeRepository.getFirst()) ?? undefined)
.setCharacterHair((await CharacterHairRepository.getFirst()) ?? undefined)
.save()

View File

@ -1,12 +1,12 @@
import { Server } from 'socket.io'
import { BaseCommand } from '#application/base/baseCommand'
import ZoneManager from '#managers/zoneManager'
import MapManager from '#managers/mapManager'
type CommandInput = string[]
export default class ListZonesCommand extends BaseCommand {
export default class ListMapsCommand extends BaseCommand {
public execute(input: CommandInput): void {
console.log(ZoneManager.getLoadedZones())
console.log(MapManager.getLoadedMaps())
}
}

View File

@ -3,12 +3,12 @@ import fs from 'fs'
import sharp from 'sharp'
import { BaseCommand } from '#application/base/baseCommand'
import { getPublicPath } from '#application/storage'
import Storage from '#application/storage'
export default class TilesCommand extends BaseCommand {
public async execute(): Promise<void> {
// Get all tiles
const tilesDir = getPublicPath('tiles')
const tilesDir = Storage.getPublicPath('tiles')
const tiles = fs.readdirSync(tilesDir).filter((file) => file.endsWith('.png'))
// Create output directory if it doesn't exist
@ -18,14 +18,14 @@ export default class TilesCommand extends BaseCommand {
for (const tile of tiles) {
// Check if tile is already 66x34
const metadata = await sharp(getPublicPath('tiles', tile)).metadata()
const metadata = await sharp(Storage.getPublicPath('tiles', tile)).metadata()
if (metadata.width === 66 && metadata.height === 34) {
this.logger.info(`Tile ${tile} already processed`)
continue
}
const inputPath = getPublicPath('tiles', tile)
const tempPath = getPublicPath('tiles', `temp_${tile}`)
const inputPath = Storage.getPublicPath('tiles', tile)
const tempPath = Storage.getPublicPath('tiles', `temp_${tile}`)
try {
await sharp(inputPath)

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { CharacterEquipment } from './characterEquipment'
@ -6,16 +8,17 @@ import { CharacterItem } from './characterItem'
import { CharacterType } from './characterType'
import { Chat } from './chat'
import { User } from './user'
import { Zone } from './zone'
import { Map } from './map'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class Character extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@ManyToOne(() => User)
@ManyToOne({ deleteRule: 'cascade' })
user!: User
@Property({ unique: true })
@ -32,7 +35,7 @@ export class Character extends BaseEntity {
// Position
@ManyToOne()
zone!: Zone
map!: Map // @TODO: Update to spawn point when current map is not found
@Property()
positionX = 0
@ -44,10 +47,10 @@ export class Character extends BaseEntity {
rotation = 0
// Customization
@ManyToOne()
@ManyToOne({ deleteRule: 'set null' })
characterType?: CharacterType | null | undefined
@ManyToOne()
@ManyToOne({ deleteRule: 'set null' })
characterHair?: CharacterHair | null | undefined
// Inventory
@ -85,7 +88,7 @@ export class Character extends BaseEntity {
@Property()
wisdom = 10
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}
@ -139,13 +142,13 @@ export class Character extends BaseEntity {
return this.chats
}
setZone(zone: Zone) {
this.zone = zone
setMap(map: Map) {
this.map = map
return this
}
getZone() {
return this.zone
getMap() {
return this.map
}
setPositionX(positionX: number) {

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Entity, Enum, ManyToOne, PrimaryKey } from '@mikro-orm/core'
import { Character } from './character'
@ -5,22 +7,23 @@ import { CharacterItem } from './characterItem'
import { BaseEntity } from '#application/base/baseEntity'
import { CharacterEquipmentSlotType } from '#application/enums'
import { UUID } from '#application/types'
@Entity()
export class CharacterEquipment extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@Enum(() => CharacterEquipmentSlotType)
slot!: CharacterEquipmentSlotType
@ManyToOne(() => Character)
@ManyToOne({ deleteRule: 'cascade' })
character!: Character
@ManyToOne(() => CharacterItem)
@ManyToOne({ deleteRule: 'cascade' })
characterItem!: CharacterItem
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { Character } from './character'
@ -5,11 +7,12 @@ import { Sprite } from './sprite'
import { BaseEntity } from '#application/base/baseEntity'
import { CharacterGender } from '#application/enums'
import { UUID } from '#application/types'
@Entity()
export class CharacterHair extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@Property()
name!: string
@ -20,13 +23,10 @@ export class CharacterHair extends BaseEntity {
@Property()
isSelectable = false
@ManyToOne(() => Sprite, { nullable: true })
@ManyToOne({ nullable: true })
sprite?: Sprite
@OneToMany(() => Character, (character) => character.characterHair)
characters = new Collection<Character>(this)
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}
@ -70,13 +70,4 @@ export class CharacterHair extends BaseEntity {
getSprite() {
return this.sprite
}
setCharacters(characters: Collection<Character>) {
this.characters = characters
return this
}
getCharacters() {
return this.characters
}
}

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { Character } from './character'
@ -5,25 +7,23 @@ import { CharacterEquipment } from './characterEquipment'
import { Item } from './item'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class CharacterItem extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@ManyToOne(() => Character)
@ManyToOne({ deleteRule: 'cascade' })
character!: Character
@ManyToOne(() => Item)
@ManyToOne({ deleteRule: 'cascade' })
item!: Item
@Property()
quantity!: number
@OneToMany(() => CharacterEquipment, (equipment) => equipment.characterItem)
characterEquipment = new Collection<CharacterEquipment>(this)
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}
@ -58,13 +58,4 @@ export class CharacterItem extends BaseEntity {
getQuantity() {
return this.quantity
}
setCharacterEquipment(characterEquipment: Collection<CharacterEquipment>) {
this.characterEquipment = characterEquipment
return this
}
getCharacterEquipment() {
return this.characterEquipment
}
}

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, Enum, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { Character } from './character'
@ -5,11 +7,12 @@ import { Sprite } from './sprite'
import { BaseEntity } from '#application/base/baseEntity'
import { CharacterGender, CharacterRace } from '#application/enums'
import { UUID } from '#application/types'
@Entity()
export class CharacterType extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@Property()
name!: string
@ -23,10 +26,7 @@ export class CharacterType extends BaseEntity {
@Property()
isSelectable = false
@OneToMany(() => Character, (character) => character.characterType)
characters = new Collection<Character>(this)
@ManyToOne(() => Sprite, { nullable: true })
@ManyToOne({ nullable: true })
sprite?: Sprite
@Property()
@ -35,7 +35,7 @@ export class CharacterType extends BaseEntity {
@Property()
updatedAt = new Date()
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}
@ -106,13 +106,4 @@ export class CharacterType extends BaseEntity {
getUpdatedAt() {
return this.updatedAt
}
setCharacters(characters: Collection<Character>) {
this.characters = characters
return this
}
getCharacters() {
return this.characters
}
}

View File

@ -1,20 +1,23 @@
import { randomUUID } from 'node:crypto'
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Character } from './character'
import { Zone } from './zone'
import { Map } from './map'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class Chat extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@ManyToOne(() => Character)
@ManyToOne({ deleteRule: 'cascade' })
character!: Character
@ManyToOne(() => Zone)
zone!: Zone
@ManyToOne({ deleteRule: 'cascade' })
map!: Map
@Property()
message!: string
@ -22,7 +25,7 @@ export class Chat extends BaseEntity {
@Property()
createdAt = new Date()
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}
@ -40,13 +43,13 @@ export class Chat extends BaseEntity {
return this.character
}
setZone(zone: Zone) {
this.zone = zone
setMap(map: Map) {
this.map = map
return this
}
getZone() {
return this.zone
getMap() {
return this.map
}
setMessage(message: string) {

View File

@ -38,9 +38,6 @@ export class Item extends BaseEntity {
@Property()
updatedAt = new Date()
@OneToMany(() => CharacterItem, (characterItem) => characterItem.item)
characters = new Collection<CharacterItem>(this)
setId(id: UUID) {
this.id = id
return this
@ -121,13 +118,4 @@ export class Item extends BaseEntity {
getUpdatedAt() {
return this.updatedAt
}
setCharacters(characters: Collection<CharacterItem>) {
this.characters = characters
return this
}
getCharacters() {
return this.characters
}
}

View File

@ -1,18 +1,21 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { Character } from './character'
import { Chat } from './chat'
import { ZoneEffect } from './zoneEffect'
import { ZoneEventTile } from './zoneEventTile'
import { ZoneEventTileTeleport } from './zoneEventTileTeleport'
import { ZoneObject } from './zoneObject'
import { MapEffect } from './mapEffect'
import { MapEventTile } from './mapEventTile'
import { MapEventTileTeleport } from './mapEventTileTeleport'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
import { PlacedMapObject } from '#entities/placedMapObject'
@Entity()
export class Zone extends BaseEntity {
export class Map extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@Property()
name!: string
@ -29,31 +32,31 @@ export class Zone extends BaseEntity {
@Property()
pvp = false
@OneToMany(() => ZoneEffect, (effect) => effect.zone)
zoneEffects = new Collection<ZoneEffect>(this)
@OneToMany(() => ZoneEventTile, (tile) => tile.zone)
zoneEventTiles = new Collection<ZoneEventTile>(this)
@OneToMany(() => ZoneEventTileTeleport, (teleport) => teleport.toZone)
zoneEventTileTeleports = new Collection<ZoneEventTileTeleport>(this)
@OneToMany(() => ZoneObject, (object) => object.zone)
zoneObjects = new Collection<ZoneObject>(this)
@OneToMany(() => Character, (character) => character.zone)
characters = new Collection<Character>(this)
@OneToMany(() => Chat, (chat) => chat.zone)
chats = new Collection<Chat>(this)
@Property()
createdAt = new Date()
@Property()
updatedAt = new Date()
setId(id: number) {
@OneToMany(() => MapEffect, (effect) => effect.map)
mapEffects = new Collection<MapEffect>(this)
@OneToMany(() => MapEventTile, (tile) => tile.map)
mapEventTiles = new Collection<MapEventTile>(this)
@OneToMany(() => MapEventTileTeleport, (teleport) => teleport.toMap)
mapEventTileTeleports = new Collection<MapEventTileTeleport>(this)
@OneToMany(() => PlacedMapObject, (object) => object.map)
placedMapObjects = new Collection<PlacedMapObject>(this)
@OneToMany(() => Character, (character) => character.map)
characters = new Collection<Character>(this)
@OneToMany(() => Chat, (chat) => chat.map)
chats = new Collection<Chat>(this)
setId(id: UUID) {
this.id = id
return this
}
@ -107,40 +110,58 @@ export class Zone extends BaseEntity {
return this.pvp
}
setZoneEffects(zoneEffects: Collection<ZoneEffect>) {
this.zoneEffects = zoneEffects
setCreatedAt(createdAt: Date) {
this.createdAt = createdAt
return this
}
getZoneEffects() {
return this.zoneEffects
getCreatedAt() {
return this.createdAt
}
setZoneEventTiles(zoneEventTiles: Collection<ZoneEventTile>) {
this.zoneEventTiles = zoneEventTiles
setUpdatedAt(updatedAt: Date) {
this.updatedAt = updatedAt
return this
}
getZoneEventTiles() {
return this.zoneEventTiles
getUpdatedAt() {
return this.updatedAt
}
setZoneEventTileTeleports(zoneEventTileTeleports: Collection<ZoneEventTileTeleport>) {
this.zoneEventTileTeleports = zoneEventTileTeleports
setMapEffects(mapEffects: Collection<MapEffect>) {
this.mapEffects = mapEffects
return this
}
getZoneEventTileTeleports() {
return this.zoneEventTileTeleports
getMapEffects() {
return this.mapEffects
}
setZoneObjects(zoneObjects: Collection<ZoneObject>) {
this.zoneObjects = zoneObjects
setMapEventTiles(mapEventTiles: Collection<MapEventTile>) {
this.mapEventTiles = mapEventTiles
return this
}
getZoneObjects() {
return this.zoneObjects
getMapEventTiles() {
return this.mapEventTiles
}
setMapEventTileTeleports(mapEventTileTeleports: Collection<MapEventTileTeleport>) {
this.mapEventTileTeleports = mapEventTileTeleports
return this
}
getMapEventTileTeleports() {
return this.mapEventTileTeleports
}
setPlacedMapObjects(placedMapObjects: Collection<PlacedMapObject>) {
this.placedMapObjects = placedMapObjects
return this
}
getPlacedMapObjects() {
return this.placedMapObjects
}
setCharacters(characters: Collection<Character>) {
@ -160,22 +181,4 @@ export class Zone extends BaseEntity {
getChats() {
return this.chats
}
setCreatedAt(createdAt: Date) {
this.createdAt = createdAt
return this
}
getCreatedAt() {
return this.createdAt
}
setUpdatedAt(updatedAt: Date) {
this.updatedAt = updatedAt
return this
}
getUpdatedAt() {
return this.updatedAt
}
}

View File

@ -2,18 +2,18 @@ import { randomUUID } from 'node:crypto'
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Zone } from './zone'
import { Map } from './map'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class ZoneEffect extends BaseEntity {
export class MapEffect extends BaseEntity {
@PrimaryKey()
id = randomUUID()
@ManyToOne(() => Zone)
zone!: Zone
@ManyToOne({ deleteRule: 'cascade' })
map!: Map
@Property()
effect!: string
@ -30,13 +30,13 @@ export class ZoneEffect extends BaseEntity {
return this.id
}
setZone(zone: Zone) {
this.zone = zone
setMap(map: Map) {
this.map = map
return this
}
getZone() {
return this.zone
getMap() {
return this.map
}
setEffect(effect: string) {

View File

@ -2,23 +2,23 @@ import { randomUUID } from 'node:crypto'
import { Entity, Enum, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Zone } from './zone'
import { ZoneEventTileTeleport } from './zoneEventTileTeleport'
import { Map } from './map'
import { MapEventTileTeleport } from './mapEventTileTeleport'
import { BaseEntity } from '#application/base/baseEntity'
import { ZoneEventTileType } from '#application/enums'
import { MapEventTileType } from '#application/enums'
import { UUID } from '#application/types'
@Entity()
export class ZoneEventTile extends BaseEntity {
export class MapEventTile extends BaseEntity {
@PrimaryKey()
id = randomUUID()
@ManyToOne(() => Zone)
zone!: Zone
@ManyToOne({ deleteRule: 'cascade' })
map!: Map
@Enum(() => ZoneEventTileType)
type!: ZoneEventTileType
@Enum(() => MapEventTileType)
type!: MapEventTileType
@Property()
positionX!: number
@ -26,8 +26,8 @@ export class ZoneEventTile extends BaseEntity {
@Property()
positionY!: number
@OneToOne(() => ZoneEventTileTeleport, (teleport) => teleport.zoneEventTile)
teleport?: ZoneEventTileTeleport
@OneToOne(() => MapEventTileTeleport, (teleport) => teleport.mapEventTile)
teleport?: MapEventTileTeleport
setId(id: UUID) {
this.id = id
@ -38,16 +38,16 @@ export class ZoneEventTile extends BaseEntity {
return this.id
}
setZone(zone: Zone) {
this.zone = zone
setMap(map: Map) {
this.map = map
return this
}
getZone() {
return this.zone
getMap() {
return this.map
}
setType(type: ZoneEventTileType) {
setType(type: MapEventTileType) {
this.type = type
return this
}
@ -74,7 +74,7 @@ export class ZoneEventTile extends BaseEntity {
return this.positionY
}
setTeleport(teleport: ZoneEventTileTeleport) {
setTeleport(teleport: MapEventTileTeleport) {
this.teleport = teleport
return this
}

View File

@ -2,22 +2,22 @@ import { randomUUID } from 'node:crypto'
import { Entity, ManyToOne, OneToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Zone } from './zone'
import { ZoneEventTile } from './zoneEventTile'
import { Map } from './map'
import { MapEventTile } from './mapEventTile'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class ZoneEventTileTeleport extends BaseEntity {
export class MapEventTileTeleport extends BaseEntity {
@PrimaryKey()
id = randomUUID()
@OneToOne(() => ZoneEventTile)
zoneEventTile!: ZoneEventTile
@OneToOne({ deleteRule: 'cascade' })
mapEventTile!: MapEventTile
@ManyToOne(() => Zone)
toZone!: Zone
@ManyToOne({ deleteRule: 'cascade' })
toMap!: Map
@Property()
toRotation!: number
@ -37,22 +37,22 @@ export class ZoneEventTileTeleport extends BaseEntity {
return this.id
}
setZoneEventTile(zoneEventTile: ZoneEventTile) {
this.zoneEventTile = zoneEventTile
setMapEventTile(mapEventTile: MapEventTile) {
this.mapEventTile = mapEventTile
return this
}
getZoneEventTile() {
return this.zoneEventTile
getMapEventTile() {
return this.mapEventTile
}
setToZone(toZone: Zone) {
this.toZone = toZone
setToMap(toMap: Map) {
this.toMap = toMap
return this
}
getToZone() {
return this.toZone
getToMap() {
return this.toMap
}
setToRotation(toRotation: number) {

View File

@ -1,8 +1,6 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import { ZoneObject } from './zoneObject'
import { Entity, PrimaryKey, Property } from '@mikro-orm/core'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@ -18,10 +16,10 @@ export class MapObject extends BaseEntity {
@Property({ type: 'json', nullable: true })
tags?: any
@Property()
@Property({ type: 'decimal', precision: 10, scale: 2 })
originX = 0
@Property()
@Property({ type: 'decimal', precision: 10, scale: 2 })
originY = 0
@Property()
@ -42,9 +40,6 @@ export class MapObject extends BaseEntity {
@Property()
updatedAt = new Date()
@OneToMany(() => ZoneObject, (zoneObject) => zoneObject.mapObject)
zoneObjects = new Collection<ZoneObject>(this)
setId(id: UUID) {
this.id = id
return this
@ -143,13 +138,4 @@ export class MapObject extends BaseEntity {
getUpdatedAt() {
return this.updatedAt
}
setZoneObjects(zoneObjects: Collection<ZoneObject>) {
this.zoneObjects = zoneObjects
return this
}
getZoneObjects() {
return this.zoneObjects
}
}

View File

@ -1,15 +1,18 @@
import { randomUUID } from 'node:crypto'
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { User } from './user'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class PasswordResetToken extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@ManyToOne(() => User)
@ManyToOne({ deleteRule: 'cascade' })
user!: User
@Property({ unique: true })
@ -18,7 +21,7 @@ export class PasswordResetToken extends BaseEntity {
@Property()
createdAt = new Date()
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}

View File

@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
import { Entity, ManyToOne, PrimaryKey, Property } from '@mikro-orm/core'
import { Zone } from './zone'
import { Map } from './map'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@ -10,14 +10,14 @@ import { MapObject } from '#entities/mapObject'
//@TODO : Rename mapObject
@Entity()
export class ZoneObject extends BaseEntity {
export class PlacedMapObject extends BaseEntity {
@PrimaryKey()
id = randomUUID()
@ManyToOne(() => Zone)
zone!: Zone
@ManyToOne({ deleteRule: 'cascade' })
map!: Map
@ManyToOne(() => MapObject)
@ManyToOne({ deleteRule: 'cascade' })
mapObject!: MapObject
@Property()
@ -41,13 +41,13 @@ export class ZoneObject extends BaseEntity {
return this.id
}
setZone(zone: Zone) {
this.zone = zone
setMap(map: Map) {
this.map = map
return this
}
getZone() {
return this.zone
getMap() {
return this.map
}
setMapObject(mapObject: MapObject) {

View File

@ -12,7 +12,7 @@ export class SpriteAction extends BaseEntity {
@PrimaryKey()
id = randomUUID()
@ManyToOne(() => Sprite)
@ManyToOne({ deleteRule: 'cascade' })
sprite!: Sprite
@Property()

View File

@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto'
import { Collection, Entity, OneToMany, PrimaryKey, Property } from '@mikro-orm/core'
import bcrypt from 'bcryptjs'
@ -5,11 +7,12 @@ import { Character } from './character'
import { PasswordResetToken } from './passwordResetToken'
import { BaseEntity } from '#application/base/baseEntity'
import { UUID } from '#application/types'
@Entity()
export class User extends BaseEntity {
@PrimaryKey()
id!: number
id = randomUUID()
@Property({ unique: true })
username!: string
@ -29,7 +32,7 @@ export class User extends BaseEntity {
@OneToMany(() => PasswordResetToken, (token) => token.user)
passwordResetTokens = new Collection<PasswordResetToken>(this)
setId(id: number) {
setId(id: UUID) {
this.id = id
return this
}

View File

@ -11,8 +11,13 @@ export default class characterHairListEvent extends BaseEvent {
}
private async handleEvent(data: IPayload, callback: (response: CharacterHair[]) => void): Promise<void> {
const items: CharacterHair[] = await characterHairRepository.getAllSelectable()
await Database.getEntityManager().populate(items, ['sprite'])
callback(items)
try {
const items: CharacterHair[] = await characterHairRepository.getAllSelectable()
await Database.getEntityManager().populate(items, ['sprite'])
return callback(items)
} catch (error) {
this.logger.error('character:hair:list error', error)
return callback([])
}
}
}

View File

@ -1,12 +1,13 @@
import { BaseEvent } from '#application/base/baseEvent'
import Database from '#application/database'
import ZoneManager from '#managers/zoneManager'
import { UUID } from '#application/types'
import MapManager from '#managers/mapManager'
import CharacterHairRepository from '#repositories/characterHairRepository'
import CharacterRepository from '#repositories/characterRepository'
import TeleportService from '#services/teleportService'
interface CharacterConnectPayload {
characterId: number
characterHairId?: number
characterId: UUID
characterHairId?: UUID
}
export default class CharacterConnectEvent extends BaseEvent {
@ -14,30 +15,14 @@ export default class CharacterConnectEvent extends BaseEvent {
this.socket.on('character:connect', this.handleEvent.bind(this))
}
/**
* Handle character connect event
* @TODO:
* 1. Check if character is already connected
* 2. Update character hair if provided
* 3. Emit character connect event
* 4. Let other clients know of new character
* @param data
* @param callback
* @private
*/
private async handleEvent(data: CharacterConnectPayload, callback: (response: any) => void): Promise<void> {
if (!this.socket.userId) {
this.emitError('User not authenticated')
return
}
try {
if (await this.checkForActiveCharacters()) {
this.emitError('You are already connected to another character')
return
}
const character = await CharacterRepository.getByUserAndId(this.socket.userId, data.characterId)
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, data.characterId)
if (!character) {
this.emitError('Character not found or does not belong to this user')
@ -56,7 +41,17 @@ export default class CharacterConnectEvent extends BaseEvent {
// Emit character connect event
callback({ character })
// @TODO: Teleport character into zone
// wait 300 ms, @TODO: Find a better way to do this, race condition
await new Promise((resolve) => setTimeout(resolve, 500))
await TeleportService.teleportCharacter(character.id, {
targetMapId: character.map.id,
targetX: character.positionX,
targetY: character.positionY,
rotation: character.rotation,
isInitialJoin: true,
character
})
} catch (error) {
this.handleError('Failed to connect character', error)
}
@ -64,6 +59,6 @@ export default class CharacterConnectEvent extends BaseEvent {
private async checkForActiveCharacters(): Promise<boolean> {
const characters = await CharacterRepository.getByUserId(this.socket.userId!)
return characters?.some((char) => ZoneManager.getCharacterById(char.id)) ?? false
return characters?.some((char) => MapManager.getCharacterById(char.id)) ?? false
}
}

View File

@ -5,6 +5,7 @@ import { ZCharacterCreate } from '#application/zodTypes'
import { Character } from '#entities/character'
import CharacterRepository from '#repositories/characterRepository'
import UserRepository from '#repositories/userRepository'
import MapRepository from '#repositories/mapRepository'
export default class CharacterCreateEvent extends BaseEvent {
public listen(): void {
@ -35,10 +36,15 @@ export default class CharacterCreateEvent extends BaseEvent {
return this.socket.emit('notification', { message: 'You can only have 4 characters' })
}
const newCharacter = new Character()
await newCharacter.setName(data.name).setUser(user).save()
// @TODO: Change to default location
const map = await MapRepository.getFirst()
if (!newCharacter) return this.socket.emit('notification', { message: 'Failed to create character. Please try again (later).' })
const newCharacter = new Character()
await newCharacter.setName(data.name).setUser(user).setMap(map!).save()
if (!newCharacter) {
return this.socket.emit('notification', { message: 'Failed to create character. Please try again (later).' })
}
characters = [...characters, newCharacter]

View File

@ -1,14 +1,14 @@
import { BaseEvent } from '#application/base/baseEvent'
import { UUID } from '#application/types'
import { Character } from '#entities/character'
import { Zone } from '#entities/zone'
import { Map } from '#entities/map'
import CharacterRepository from '#repositories/characterRepository'
type TypePayload = {
characterId: number
characterId: UUID
}
type TypeResponse = {
zone: Zone
characters: Character[]
}
@ -19,11 +19,7 @@ export default class CharacterDeleteEvent extends BaseEvent {
private async handleEvent(data: TypePayload, callback: (response: TypeResponse) => void): Promise<any> {
try {
const character = await CharacterRepository.getByUserAndId(this.socket.userId!, data.characterId)
if (character) {
await character.delete()
}
await (await CharacterRepository.getByUserAndId(this.socket.userId!, data.characterId))?.delete()
const characters: Character[] = await CharacterRepository.getByUserId(this.socket.userId!)
this.socket.emit('character:list', characters)

View File

@ -1,5 +1,4 @@
import { BaseEvent } from '#application/base/baseEvent'
import Database from '#application/database'
import { Character } from '#entities/character'
import CharacterRepository from '#repositories/characterRepository'
@ -10,9 +9,7 @@ export default class CharacterListEvent extends BaseEvent {
private async handleEvent(data: any): Promise<void> {
try {
const characters: Character[] = await CharacterRepository.getByUserId(this.socket.userId!)
await Database.getEntityManager().populate(characters, ['characterType', 'characterHair'])
let characters: Character[] = await CharacterRepository.getByUserId(this.socket.userId!, ['characterType', 'characterHair'])
this.socket.emit('character:list', characters)
} catch (error: any) {
this.logger.error('character:list error', error.message)

View File

@ -1,9 +1,9 @@
import { BaseEvent } from '#application/base/baseEvent'
import ZoneManager from '#managers/zoneManager'
import zoneManager from '#managers/zoneManager'
import ZoneCharacter from '#models/zoneCharacter'
import ZoneRepository from '#repositories/zoneRepository'
import { UUID } from '#application/types'
import MapManager from '#managers/mapManager'
import MapRepository from '#repositories/mapRepository'
import ChatService from '#services/chatService'
import TeleportService from '#services/teleportService'
type TypePayload = {
message: string
@ -14,18 +14,16 @@ export default class TeleportCommandEvent extends BaseEvent {
this.socket.on('chat:message', this.handleEvent.bind(this))
}
private async handleEvent(data: TypePayload, callback: (response: boolean) => void): Promise<void> {
private async handleEvent(data: TypePayload, callback: (response: boolean) => void) {
try {
// Check if character exists
const zoneCharacter = ZoneManager.getCharacterById(this.socket.characterId!)
if (!zoneCharacter) {
const mapCharacter = MapManager.getCharacterById(this.socket.characterId!)
if (!mapCharacter) {
this.logger.error('chat:message error', 'Character not found')
return
}
const character = zoneCharacter.character
const character = mapCharacter.character
// Check if the user is the GM
if (character.role !== 'gm') {
this.logger.info(`User ${character.id} tried to set time but is not a game master.`)
return
@ -35,54 +33,68 @@ export default class TeleportCommandEvent extends BaseEvent {
const args = ChatService.getArgs('teleport', data.message)
if (!args || args.length !== 1) {
this.socket.emit('notification', { title: 'Server message', message: 'Usage: /teleport <zoneId>' })
if (!args || args.length === 0 || args.length > 3) {
this.socket.emit('notification', {
title: 'Server message',
message: 'Usage: /teleport <mapId> [x] [y]'
})
return
}
const zoneId = parseInt(args[0], 10)
if (isNaN(zoneId)) {
this.socket.emit('notification', { title: 'Server message', message: 'Invalid zone ID' })
const mapId = args[0] as UUID
const targetX = args[1] ? parseInt(args[1], 10) : 0
const targetY = args[2] ? parseInt(args[2], 10) : 0
if (!mapId || isNaN(targetX) || isNaN(targetY)) {
this.socket.emit('notification', {
title: 'Server message',
message: 'Invalid parameters. X and Y coordinates must be numbers.'
})
return
}
const zone = await ZoneRepository.getById(zoneId)
if (!zone) {
this.socket.emit('notification', { title: 'Server message', message: 'Zone not found' })
const map = await MapRepository.getById(mapId)
if (!map) {
this.socket.emit('notification', {
title: 'Server message',
message: 'Map not found'
})
return
}
if (character.zoneId === zone.id) {
this.socket.emit('notification', { title: 'Server message', message: 'You are already in that zone' })
if (character.map.id === map.id && targetX === character.positionX && targetY === character.positionY) {
this.socket.emit('notification', {
title: 'Server message',
message: 'You are already at that location'
})
return
}
// Remove character from current zone
zoneManager.removeCharacter(character.id)
this.io.to(character.zoneId.toString()).emit('zone:character:leave', character.id)
this.socket.leave(character.zoneId.toString())
// Add character to new zone
zoneManager.getZoneById(zone.id)?.addCharacter(character)
this.io.to(zone.id.toString()).emit('zone:character:join', character)
this.socket.join(zone.id.toString())
character.zoneId = zone.id
character.positionX = 0
character.positionY = 0
zoneCharacter.isMoving = false
this.socket.emit('zone:character:teleport', {
zone,
characters: ZoneManager.getZoneById(zone.id)?.getCharactersInZone()
const success = await TeleportService.teleportCharacter(character.id, {
targetMapId: map.id,
targetX,
targetY,
rotation: character.rotation
})
this.socket.emit('notification', { title: 'Server message', message: `You have been teleported to ${zone.name}` })
this.logger.info('teleport', `Character ${character.id} teleported to zone ${zone.id}`)
if (!success) {
return this.socket.emit('notification', {
title: 'Server message',
message: 'Failed to teleport'
})
}
this.socket.emit('notification', {
title: 'Server message',
message: `Teleported to ${map.name} (${targetX}, ${targetY})`
})
this.logger.info('teleport', `Character ${character.id} teleported to map ${map.id} at position (${targetX}, ${targetY})`)
} catch (error: any) {
this.logger.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,6 +1,6 @@
import { BaseEvent } from '#application/base/baseEvent'
import ZoneManager from '#managers/zoneManager'
import ZoneRepository from '#repositories/zoneRepository'
import MapManager from '#managers/mapManager'
import MapRepository from '#repositories/mapRepository'
import ChatService from '#services/chatService'
type TypePayload = {
@ -18,21 +18,21 @@ export default class ChatMessageEvent extends BaseEvent {
return callback(false)
}
const zoneCharacter = ZoneManager.getCharacterById(this.socket.characterId!)
if (!zoneCharacter) {
const mapCharacter = MapManager.getCharacterById(this.socket.characterId!)
if (!mapCharacter) {
this.logger.error('chat:message error', 'Character not found')
return callback(false)
}
const character = zoneCharacter.character
const character = mapCharacter.character
const zone = await ZoneRepository.getById(character.zone?.id!)
if (!zone) {
this.logger.error('chat:message error', 'Zone not found')
const map = await MapRepository.getById(character.map.id)
if (!map) {
this.logger.error('chat:message error', 'Map not found')
return callback(false)
}
if (await ChatService.sendZoneMessage(this.io, this.socket, data.message, character.id, zone.id)) {
if (await ChatService.sendMapMessage(character.getId(), map.getId(), data.message)) {
return callback(true)
}

View File

@ -1,12 +1,12 @@
import { BaseEvent } from '#application/base/baseEvent'
import ZoneManager from '#managers/zoneManager'
import MapManager from '#managers/mapManager'
export default class DisconnectEvent extends BaseEvent {
public listen(): void {
this.socket.on('disconnect', this.handleEvent.bind(this))
}
private async handleEvent(data: any): Promise<void> {
private async handleEvent(): Promise<void> {
try {
if (!this.socket.userId) {
this.logger.info('User disconnected but had no user set')
@ -15,24 +15,14 @@ export default class DisconnectEvent extends BaseEvent {
this.io.emit('user:disconnect', this.socket.userId)
const zoneCharacter = ZoneManager.getCharacterById(this.socket.characterId!)
if (!zoneCharacter) {
const mapCharacter = MapManager.getCharacterById(this.socket.characterId!)
if (!mapCharacter) {
this.logger.info('User disconnected but had no character set')
return
}
const character = zoneCharacter.character
// Save character position and remove from zone
zoneCharacter.isMoving = false
await zoneCharacter.savePosition()
ZoneManager.removeCharacter(this.socket.characterId!)
await mapCharacter.disconnect(this.socket, this.io)
this.logger.info('User disconnected along with their character')
// Inform other clients that the character has left
this.io.in(character.zone!.id.toString()).emit('zone:character:leave', character.id)
this.io.emit('character:disconnect', character.id)
} catch (error: any) {
this.logger.error('disconnect error: ' + error.message)
}

View File

@ -9,7 +9,7 @@ export default class CharacterHairCreateEvent extends BaseEvent {
private async handleEvent(data: undefined, callback: (response: boolean, characterType?: any) => void): Promise<void> {
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.role !== 'gm') {

View File

@ -1,9 +1,9 @@
import { BaseEvent } from '#application/base/baseEvent'
import CharacterHairRepository from '#repositories/characterHairRepository'
import characterRepository from '#repositories/characterRepository'
import { UUID } from '#application/types'
interface IPayload {
id: number
id: UUID
}
export default class characterHairDeleteEvent extends BaseEvent {
@ -12,20 +12,13 @@ export default class characterHairDeleteEvent extends BaseEvent {
}
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 {
const characterHair = await CharacterHairRepository.getById(data.id)
if (characterHair) {
await characterHair.delete()
}
if (!(await this.isCharacterGM())) return
callback(true)
const characterHair = await CharacterHairRepository.getById(data.id)
await (await CharacterHairRepository.getById(data.id))?.delete()
return callback(true)
} catch (error) {
this.logger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)

View File

@ -1,7 +1,6 @@
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterHair } from '#entities/characterHair'
import characterHairRepository from '#repositories/characterHairRepository'
import characterRepository from '#repositories/characterRepository'
interface IPayload {}
@ -11,19 +10,14 @@ export default class characterHairListEvent extends BaseEvent {
}
private async handleEvent(data: IPayload, callback: (response: CharacterHair[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) {
this.logger.error('gm:characterHair:list error', 'Character not found')
try {
if (!(await this.isCharacterGM())) return
const items = await characterHairRepository.getAll()
return callback(items)
} catch (error) {
this.logger.error('gm:characterHair:list error', error)
return callback([])
}
if (character.role !== 'gm') {
this.logger.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

@ -6,7 +6,7 @@ import characterRepository from '#repositories/characterRepository'
import SpriteRepository from '#repositories/spriteRepository'
type Payload = {
id: number
id: UUID
name: string
gender: CharacterGender
isSelectable: boolean
@ -19,21 +19,17 @@ export default class CharacterHairUpdateEvent extends BaseEvent {
}
private async handleEvent(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 {
if (!(await this.isCharacterGM())) return
const sprite = await SpriteRepository.getById(data.spriteId)
const characterHair = await CharacterHairRepository.getById(data.id)
if (characterHair) {
await characterHair.setName(data.name).setGender(data.gender).setIsSelectable(data.isSelectable).setSprite(sprite!).update()
if (!characterHair) {
return callback(false)
}
await characterHair.setName(data.name).setGender(data.gender).setIsSelectable(data.isSelectable).setSprite(sprite!).update()
return callback(true)
} catch (error) {
this.logger.error(`Error updating character hair: ${error instanceof Error ? error.message : String(error)}`)

View File

@ -1,41 +1,22 @@
import { CharacterGender, CharacterRace } from '@prisma/client'
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
export default class CharacterTypeCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterType } from '#entities/characterType'
export default class CharacterTypeCreateEvent extends BaseEvent {
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 (!(await this.isCharacterGM())) return
if (character.role !== 'gm') {
return callback(false)
}
const newCharacterType = new CharacterType()
await newCharacterType.setName('New character type').save()
const newCharacterType = await prisma.characterType.create({
data: {
name: 'New character type',
gender: CharacterGender.MALE,
race: CharacterRace.HUMAN
}
})
callback(true, newCharacterType)
return callback(true, newCharacterType)
} catch (error) {
console.error('Error creating character type:', error)
callback(false)
return callback(false)
}
}
}

View File

@ -1,41 +1,28 @@
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { UUID } from '#application/types'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
import { BaseEvent } from '#application/base/baseEvent'
interface IPayload {
id: number
id: UUID
}
export default class CharacterTypeDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class CharacterTypeDeleteEvent extends BaseEvent {
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 {
if (!(await this.isCharacterGM())) return
const characterType = await CharacterTypeRepository.getById(data.id)
if (!characterType) return callback(false)
await characterType.delete()
callback(true)
return callback(true)
} catch (error) {
gameMasterLogger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
this.logger.error(`Error deleting character type ${data.id}: ${error instanceof Error ? error.message : String(error)}`)
return callback(false)
}
}
}

View File

@ -1,37 +1,23 @@
import { CharacterType } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
import { BaseEvent } from '#application/base/baseEvent'
import { CharacterType } from '#entities/characterType'
interface IPayload {}
export default class CharacterTypeListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class CharacterTypeListEvent extends BaseEvent {
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')
try {
if (!(await this.isCharacterGM())) return
const items = await CharacterTypeRepository.getAll()
return callback(items)
} catch (error) {
this.logger.error('gm:characterType:list error', error)
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

@ -25,7 +25,7 @@ export default class CharacterTypeUpdateEvent {
}
private async handleEvent(data: Payload, callback: (success: boolean) => 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(false)
if (character.role !== 'gm') {

View File

@ -0,0 +1,19 @@
import ObjectRepository from '#repositories/mapObjectRepository'
import { BaseEvent } from '#application/base/baseEvent'
import { MapObject } from '#entities/mapObject'
interface IPayload {}
export default class MapObjectListEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:mapObject:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: MapObject[]) => void): Promise<void> {
if (!(await this.isCharacterGM())) return
// get all objects
const objects = await ObjectRepository.getAll()
return callback(objects)
}
}

View File

@ -0,0 +1,38 @@
import fs from 'fs'
import Storage from '#application/storage'
import { BaseEvent } from '#application/base/baseEvent'
import MapObjectRepository from '#repositories/mapObjectRepository'
import { UUID } from '#application/types'
interface IPayload {
mapObjectId: UUID
}
export default class MapObjectRemoveEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:mapObject:remove', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: boolean) => void): Promise<void> {
if (!(await this.isCharacterGM())) return
try {
// remove the tile from the disk
const finalFilePath = Storage.getPublicPath('map_objects', data.mapObjectId + '.png')
fs.unlink(finalFilePath, async (err) => {
if (err) {
this.logger.error(`Error deleting object ${data.mapObjectId}: ${err.message}`)
callback(false)
return
}
await (await MapObjectRepository.getById(data.mapObjectId))?.delete()
return callback(true)
})
} catch (error) {
this.logger.error(`Error deleting object ${data.mapObjectId}: ${error instanceof Error ? error.message : String(error)}`)
return callback(false)
}
}
}

View File

@ -0,0 +1,46 @@
import { UUID } from '#application/types'
import { BaseEvent } from '#application/base/baseEvent'
import MapObjectRepository from '#repositories/mapObjectRepository'
type Payload = {
id: UUID
name: string
tags: string[]
originX: number
originY: number
isAnimated: boolean
frameRate: number
frameWidth: number
frameHeight: number
}
export default class MapObjectUpdateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:mapObject:update', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
const mapObject = await MapObjectRepository.getById(data.id)
if (!mapObject) return callback(false)
await mapObject
.setName(data.name)
.setTags(data.tags)
.setOriginX(data.originX)
.setOriginY(data.originY)
.setIsAnimated(data.isAnimated)
.setFrameRate(data.frameRate)
.setFrameWidth(data.frameWidth)
.setFrameHeight(data.frameHeight)
.update()
return callback(true)
} catch (error) {
console.error(error)
return callback(false)
}
}
}

View File

@ -0,0 +1,53 @@
import fs from 'fs/promises'
import { writeFile } from 'node:fs/promises'
import sharp from 'sharp'
import Storage from '#application/storage'
import { BaseEvent } from '#application/base/baseEvent'
import { MapObject } from '#entities/mapObject'
interface IObjectData {
[key: string]: Buffer
}
export default class MapObjectUploadEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:mapObject:upload', this.handleEvent.bind(this))
}
private async handleEvent(data: IObjectData, callback: (response: boolean) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
const public_folder = Storage.getPublicPath('map_objects')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const uploadPromises = Object.entries(data).map(async ([key, objectData]) => {
// Get image dimensions
const metadata = await sharp(objectData).metadata()
const width = metadata.width || 0
const height = metadata.height || 0
// Create new map object and save it to database
const mapObject = new MapObject()
await mapObject.setName(key).setTags([]).setOriginX(0).setOriginY(0).setFrameWidth(width).setFrameHeight(height).save()
// Save image to disk
const uuid = mapObject.getId()
const filename = `${uuid}.png`
const finalFilePath = Storage.getPublicPath('map_objects', filename)
await writeFile(finalFilePath, objectData)
this.logger.info('gm:mapObject:upload', `Object ${key} uploaded with id ${uuid}`)
})
await Promise.all(uploadPromises)
return callback(true)
} catch (error: any) {
this.logger.error('gm:mapObject:upload error', error.message)
return callback(false)
}
}
}

View File

@ -1,32 +0,0 @@
import { Object } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import ObjectRepository from '#repositories/objectRepository'
interface IPayload {}
export default class ObjectListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Object[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback([])
if (character.role !== 'gm') {
return callback([])
}
// get all objects
const objects = await ObjectRepository.getAll()
callback(objects)
}
}

View File

@ -1,59 +0,0 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { gameLogger, gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface IPayload {
object: string
}
export default class ObjectRemoveEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:remove', this.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.object.delete({
where: {
id: data.object
}
})
// get root path
const public_folder = getPublicPath('objects')
// remove the tile from the disk
const finalFilePath = getPublicPath('objects', data.object + '.png')
fs.unlink(finalFilePath, (err) => {
if (err) {
gameMasterLogger.error(`Error deleting object ${data.object}: ${err.message}`)
callback(false)
return
}
callback(true)
})
} catch (error) {
gameLogger.error(`Error deleting object ${data.object}: ${error instanceof Error ? error.message : String(error)}`)
callback(false)
}
}
}

View File

@ -1,59 +0,0 @@
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
type Payload = {
id: string
name: string
tags: string[]
originX: number
originY: number
isAnimated: boolean
frameRate: number
frameWidth: number
frameHeight: number
}
export default class ObjectUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:update', this.handleEvent.bind(this))
}
private async handleEvent(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 {
const object = await prisma.object.update({
where: {
id: data.id
},
data: {
name: data.name,
tags: data.tags,
originX: data.originX,
originY: data.originY,
isAnimated: data.isAnimated,
frameRate: data.frameRate,
frameWidth: data.frameWidth,
frameHeight: data.frameHeight
}
})
callback(true)
} catch (error) {
console.error(error)
callback(false)
}
}
}

View File

@ -1,73 +0,0 @@
import fs from 'fs/promises'
import { writeFile } from 'node:fs/promises'
import sharp from 'sharp'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
interface IObjectData {
[key: string]: Buffer
}
export default class ObjectUploadEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:object:upload', this.handleEvent.bind(this))
}
private async handleEvent(data: IObjectData, callback: (response: boolean) => 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 public_folder = getPublicPath('objects')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const uploadPromises = Object.entries(data).map(async ([key, objectData]) => {
// Get image dimensions
const metadata = await sharp(objectData).metadata()
const width = metadata.width || 0
const height = metadata.height || 0
const object = await prisma.object.create({
data: {
name: key,
tags: [],
originX: 0,
originY: 0,
frameWidth: width,
frameHeight: height
}
})
const uuid = object.id
const filename = `${uuid}.png`
const finalFilePath = getPublicPath('objects', filename)
await writeFile(finalFilePath, objectData)
gameMasterLogger.info('gm:object:upload', `Object ${key} uploaded with id ${uuid}`)
})
await Promise.all(uploadPromises)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:object:upload error', error.message)
callback(false)
}
}
}

View File

@ -1,79 +1,35 @@
import { Server } from 'socket.io'
import type { Prisma } from '@prisma/client'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import { BaseEvent } from '#application/base/baseEvent'
import { UUID } from '#application/types'
import { Sprite } from '#entities/sprite'
import CharacterRepository from '#repositories/characterRepository'
import SpriteRepository from '#repositories/spriteRepository'
interface CopyPayload {
id: string
id: UUID
}
export default class SpriteCopyEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class SpriteCopyEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:sprite:copy', this.handleEvent.bind(this))
}
private async handleEvent(payload: CopyPayload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.validateGameMasterAccess())) {
return callback(false)
}
if (!(await this.isCharacterGM())) return
const sourceSprite = await prisma.sprite.findUnique({
where: { id: payload.id },
include: {
spriteActions: true
}
})
const sourceSprite = await SpriteRepository.getById(payload.id)
if (!sourceSprite) {
throw new Error('Source sprite not found')
}
const newSprite = await prisma.sprite.create({
data: {
name: `${sourceSprite.name} (Copy)`,
spriteActions: {
create: sourceSprite.spriteActions.map((action) => ({
action: action.action,
sprites: action.sprites as Prisma.InputJsonValue,
originX: action.originX,
originY: action.originY,
isAnimated: action.isAnimated,
isLooping: action.isLooping,
frameWidth: action.frameWidth,
frameHeight: action.frameHeight,
frameRate: action.frameRate
}))
}
}
})
const newSprite = new Sprite()
await newSprite.setName(`${sourceSprite.getName()} (Copy)`).setSpriteActions(sourceSprite.getSpriteActions()).save()
callback(true)
} catch (error) {
this.handleError(error, payload.id, callback)
this.logger.error(`Error copying sprite:`, String(error))
callback(false)
}
}
private async validateGameMasterAccess(): Promise<boolean> {
const character = await CharacterRepository.getById(this.socket.characterId!)
return character?.role === 'gm'
}
private handleError(error: unknown, spriteId: string, callback: (success: boolean) => void): void {
gameMasterLogger.error(`Error copying sprite ${spriteId}: ${this.getErrorMessage(error)}`)
callback(false)
}
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
}

View File

@ -1,51 +1,36 @@
import fs from 'fs/promises'
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
export default class SpriteCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
import { BaseEvent } from '#application/base/baseEvent'
import Storage from '#application/storage'
import { Sprite } from '#entities/sprite'
export default class SpriteCreateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:sprite:create', this.handleEvent.bind(this))
}
private async handleEvent(data: undefined, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback(false)
if (!(await this.isCharacterGM())) return
if (character.role !== 'gm') {
return callback(false)
}
const public_folder = getPublicPath('sprites')
const public_folder = Storage.getPublicPath('sprites')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const sprite = await prisma.sprite.create({
data: {
name: 'New sprite'
}
})
const sprite = new Sprite()
await sprite.setName('New sprite').save()
const uuid = sprite.id
// Create folder with uuid
const sprite_folder = getPublicPath('sprites', uuid)
const sprite_folder = Storage.getPublicPath('sprites', uuid)
await fs.mkdir(sprite_folder, { recursive: true })
callback(true)
return callback(true)
} catch (error) {
console.error('Error creating sprite:', error)
callback(false)
return callback(false)
}
}
}

View File

@ -1,62 +1,39 @@
import fs from 'fs'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import Storage from '#application/storage'
import { UUID } from '#application/types'
import SpriteRepository from '#repositories/spriteRepository'
type Payload = {
id: string
id: UUID
}
export default class GMSpriteDeleteEvent {
private readonly public_folder: string
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = getPublicPath('sprites')
}
export default class GMSpriteDeleteEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:sprite:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
const character = await CharacterRepository.getById(this.socket.characterId!)
if (character?.role !== 'gm') {
return callback(false)
}
if (!(await this.isCharacterGM())) return
try {
await this.deleteSpriteFolder(data.id)
await this.deleteSpriteFromDatabase(data.id)
await (await SpriteRepository.getById(data.id))?.delete()
gameMasterLogger.info(`Sprite ${data.id} deleted.`)
this.logger.info(`Sprite ${data.id} deleted.`)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:sprite:delete error', error.message)
this.logger.error('gm:sprite:delete error', error.message)
callback(false)
}
}
private async deleteSpriteFolder(spriteId: string): Promise<void> {
const finalFilePath = getPublicPath('sprites', spriteId)
const finalFilePath = Storage.getPublicPath('sprites', spriteId)
if (fs.existsSync(finalFilePath)) {
await fs.promises.rmdir(finalFilePath, { recursive: true })
}
}
private async deleteSpriteFromDatabase(spriteId: string): Promise<void> {
await prisma.sprite.delete({
where: {
id: spriteId
}
})
}
}

View File

@ -1,29 +1,17 @@
import { Sprite } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import SpriteRepository from '#repositories/spriteRepository'
interface IPayload {}
export default class SpriteListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class SpriteListEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:sprite:list', this.handleEvent.bind(this))
}
private async handleEvent(data: any, callback: (response: Sprite[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId!)
if (!character) return callback([])
if (character.role !== 'gm') {
return callback([])
}
private async handleEvent(data: IPayload, callback: (response: Sprite[]) => void): Promise<void> {
if (!(await this.isCharacterGM())) return
// get all sprites
const sprites = await SpriteRepository.getAll()

View File

@ -1,15 +1,11 @@
import { writeFile, mkdir } from 'node:fs/promises'
import sharp from 'sharp'
import { Server } from 'socket.io'
import type { Prisma, SpriteAction } from '@prisma/client'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import Storage from '#application/storage'
import { SpriteAction } from '#entities/spriteAction'
import SpriteRepository from '#repositories/spriteRepository'
// Constants
const ISOMETRIC_CONFIG = {
@ -41,7 +37,7 @@ interface SpriteActionInput extends Omit<SpriteAction, 'id' | 'spriteId' | 'fram
interface UpdatePayload {
id: string
name: string
spriteActions: Prisma.JsonValue
spriteActions: SpriteAction[]
}
interface ProcessedSpriteAction extends SpriteActionInput {
@ -62,21 +58,14 @@ interface SpriteAnalysis {
contentBounds: ContentBounds
}
export default class SpriteUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class SpriteUpdateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:sprite:update', this.handleEvent.bind(this))
}
private async handleEvent(payload: UpdatePayload, callback: (success: boolean) => void): Promise<void> {
try {
if (!(await this.validateGameMasterAccess())) {
return callback(false)
}
if (!(await this.isCharacterGM())) return
const parsedActions = this.validateSpriteActions(payload.spriteActions)
@ -111,11 +100,6 @@ export default class SpriteUpdateEvent {
}
}
private async validateGameMasterAccess(): Promise<boolean> {
const character = await CharacterRepository.getById(this.socket.characterId!)
return character?.role === 'gm'
}
private validateSpriteActions(actions: Prisma.JsonValue): SpriteActionInput[] {
try {
const parsed = JSON.parse(JSON.stringify(actions)) as SpriteActionInput[]
@ -314,13 +298,13 @@ export default class SpriteUpdateEvent {
}
private async saveSpritesToDisk(id: string, actions: ProcessedSpriteAction[]): Promise<void> {
const publicFolder = getPublicPath('sprites', id)
const publicFolder = Storage.getPublicPath('sprites', id)
await mkdir(publicFolder, { recursive: true })
await Promise.all(
actions.map(async (action) => {
const spritesheet = await this.createSpritesheet(action.buffersWithDimensions)
await writeFile(getPublicPath('sprites', id, `${action.action}.png`), spritesheet)
await writeFile(Storage.getPublicPath('sprites', id, `${action.action}.png`), spritesheet)
})
)
}
@ -375,6 +359,8 @@ export default class SpriteUpdateEvent {
}
}
})
await (await SpriteRepository.getById(id))?.setName(name).setSpriteActions(actions).update()
}
private mapActionToDatabase(action: ProcessedSpriteAction) {
@ -392,7 +378,7 @@ export default class SpriteUpdateEvent {
}
private handleError(error: unknown, spriteId: string, callback: (success: boolean) => void): void {
gameMasterLogger.error(`Error updating sprite ${spriteId}: ${this.getErrorMessage(error)}`)
this.logger.error(`Error updating sprite ${spriteId}: ${this.getErrorMessage(error)}`)
callback(false)
}

View File

@ -1,69 +1,45 @@
import fs from 'fs/promises'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import Storage from '#application/storage'
import { UUID } from '#application/types'
import TileRepository from '#repositories/tileRepository'
type Payload = {
id: string
id: UUID
}
export default class GMTileDeleteEvent {
private readonly public_folder: string
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {
this.public_folder = getPublicPath('tiles')
}
export default class GMTileDeleteEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:tile:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, 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
}
if (!(await this.isCharacterGM())) return
try {
gameMasterLogger.info(`Deleting tile ${data.id}`)
await this.deleteTileFromDatabase(data.id)
this.logger.info(`Deleting tile ${data.id}`)
await this.deleteTileFile(data.id)
await (await TileRepository.getById(data.id))?.delete()
gameMasterLogger.info(`Tile ${data.id} deleted successfully.`)
callback(true)
} catch (error: any) {
gameMasterLogger.error('gm:tile:delete error', error.message)
callback(false)
this.logger.info(`Tile ${data.id} deleted successfully.`)
return callback(true)
} catch (error: unknown) {
this.logger.error('gm:tile:delete error', error)
return callback(false)
}
}
private async deleteTileFromDatabase(tileId: string): Promise<void> {
await prisma.tile.delete({
where: {
id: tileId
}
})
}
private async deleteTileFile(tileId: string): Promise<void> {
const finalFilePath = getPublicPath('tiles', `${tileId}.png`)
const finalFilePath = Storage.getPublicPath('tiles', `${tileId}.png`)
try {
await fs.unlink(finalFilePath)
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error
}
gameMasterLogger.warn(`File ${finalFilePath} does not exist.`)
this.logger.warn(`File ${finalFilePath} does not exist.`)
}
}
}

View File

@ -1,32 +1,19 @@
import { Tile } from '@prisma/client'
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import { Tile } from '#entities/tile'
import TileRepository from '#repositories/tileRepository'
interface IPayload {}
export default class TileListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class TileListEven extends BaseEvent {
public listen(): void {
this.socket.on('gm:tile:list', this.handleEvent.bind(this))
}
private async handleEvent(data: any, callback: (response: Tile[]) => void): Promise<void> {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return
if (character.role !== 'gm') {
return
}
private async handleEvent(data: IPayload, callback: (response: Tile[]) => void): Promise<void> {
if (!(await this.isCharacterGM())) return
// get all tiles
const tiles = await TileRepository.getAll()
callback(tiles)
return callback(tiles)
}
}

View File

@ -1,48 +1,29 @@
import { Server } from 'socket.io'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import { UUID } from '#application/types'
import TileRepository from '#repositories/tileRepository'
type Payload = {
id: string
id: UUID
name: string
tags: string[]
}
export default class TileUpdateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class TileUpdateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:tile:update', this.handleEvent.bind(this))
}
private async handleEvent(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
}
if (!(await this.isCharacterGM())) return
try {
const Tile = await prisma.tile.update({
where: {
id: data.id
},
data: {
name: data.name,
tags: data.tags
}
})
const tile = await TileRepository.getById(data.id)
if (!tile) return callback(false)
callback(true)
await tile.setName(data.name).setTags(data.tags).update()
return callback(true)
} catch (error) {
console.error(error)
callback(false)
return callback(false)
}
}
}

View File

@ -1,60 +1,43 @@
import fs from 'fs/promises'
import { writeFile } from 'node:fs/promises'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { getPublicPath } from '#application/storage'
import { TSocket } from '#application/types'
import characterRepository from '#repositories/characterRepository'
import { BaseEvent } from '#application/base/baseEvent'
import Storage from '#application/storage'
import { Tile } from '#entities/tile'
interface ITileData {
[key: string]: Buffer
}
export default class TileUploadEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
export default class TileUploadEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:tile:upload', this.handleEvent.bind(this))
}
private async handleEvent(data: ITileData, callback: (response: boolean) => void): Promise<void> {
try {
const character = await characterRepository.getById(this.socket.characterId as number)
if (!character) return callback(false)
if (!(await this.isCharacterGM())) return
if (character.role !== 'gm') {
return
}
const public_folder = getPublicPath('tiles')
const public_folder = Storage.getPublicPath('tiles')
// Ensure the folder exists
await fs.mkdir(public_folder, { recursive: true })
const uploadPromises = Object.entries(data).map(async ([key, tileData]) => {
const tile = await prisma.tile.create({
data: {
name: 'New tile'
}
})
const uuid = tile.id
const tile = new Tile()
await tile.setName('New tile').save()
const uuid = tile.getId()
const filename = `${uuid}.png`
const finalFilePath = getPublicPath('tiles', filename)
const finalFilePath = Storage.getPublicPath('tiles', filename)
await writeFile(finalFilePath, tileData)
})
await Promise.all(uploadPromises)
callback(true)
return callback(true)
} catch (error) {
gameMasterLogger.error('Error uploading tile:', error)
callback(false)
this.logger.error('Error uploading tile:', error)
return callback(false)
}
}
}

View File

@ -0,0 +1,38 @@
import { BaseEvent } from '#application/base/baseEvent'
import { Map } from '#entities/map'
import MapRepository from '#repositories/mapRepository'
type Payload = {
name: string
width: number
height: number
}
export default class MapCreateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:map:create', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: Map[]) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
this.logger.info(`User ${(await this.getCharacter())!.getId()} has created a new map via map editor.`)
const map = new Map()
await map
.setName(data.name)
.setWidth(data.width)
.setHeight(data.height)
.setTiles(Array.from({ length: data.height }, () => Array.from({ length: data.width }, () => 'blank_tile')))
.save()
const mapList = await MapRepository.getAll()
return callback(mapList)
} catch (error: any) {
this.logger.error('gm:map:create error', error.message)
this.socket.emit('notification', { message: 'Failed to create map.' })
return callback([])
}
}
}

View File

@ -0,0 +1,29 @@
import { BaseEvent } from '#application/base/baseEvent'
import { UUID } from '#application/types'
import MapRepository from '#repositories/mapRepository'
type Payload = {
mapId: UUID
}
export default class MapDeleteEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:map:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
if (!(await this.isCharacterGM())) return
try {
this.logger.info(`Deleting map ${data.mapId}`)
await (await MapRepository.getById(data.mapId))?.delete()
this.logger.info(`Map ${data.mapId} deleted successfully.`)
return callback(true)
} catch (error: unknown) {
this.logger.error('gm:map:delete error', error)
return callback(false)
}
}
}

View File

@ -0,0 +1,25 @@
import { BaseEvent } from '#application/base/baseEvent'
import { Map } from '#entities/map'
import MapRepository from '#repositories/mapRepository'
interface IPayload {}
export default class MapListEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:map:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Map[]) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
this.logger.info(`User ${(await this.getCharacter())!.getId()} has created a new map via map editor.`)
const maps = await MapRepository.getAll()
return callback(maps)
} catch (error: any) {
this.logger.error('gm:map:list error', error.message)
return callback([])
}
}
}

View File

@ -0,0 +1,44 @@
import { BaseEvent } from '#application/base/baseEvent'
import { UUID } from '#application/types'
import { Map } from '#entities/map'
import MapRepository from '#repositories/mapRepository'
import Database from '#application/database'
interface IPayload {
mapId: UUID
}
export default class MapRequestEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:map:request', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Map | null) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
this.logger.info(`User ${(await this.getCharacter())!.getId()} has requested map via map editor.`)
if (!data.mapId) {
this.logger.info(`User ${(await this.getCharacter())!.getId()} tried to request map but did not provide a map id.`)
return callback(null)
}
const map = await MapRepository.getById(data.mapId)
if (!map) {
this.logger.info(`User ${(await this.getCharacter())!.getId()} tried to request map ${data.mapId} but it does not exist.`)
return callback(null)
}
console.log(map)
await Database.getEntityManager().populate(map, ['mapEventTiles', 'placedMapObjects'])
return callback(map)
} catch (error: any) {
this.logger.error('gm:map:request error', error.message)
return callback(null)
}
}
}

View File

@ -0,0 +1,130 @@
import { BaseEvent } from '#application/base/baseEvent'
import { MapEventTileType } from '#application/enums'
import { UUID } from '#application/types'
import { Map } from '#entities/map'
import { MapEffect } from '#entities/mapEffect'
import { MapEventTile } from '#entities/mapEventTile'
import { MapEventTileTeleport } from '#entities/mapEventTileTeleport'
import mapManager from '#managers/mapManager'
import MapRepository from '#repositories/mapRepository'
import { PlacedMapObject } from '#entities/placedMapObject'
interface IPayload {
mapId: UUID
name: string
width: number
height: number
tiles: string[][]
pvp: boolean
mapEventTiles: {
type: MapEventTileType
positionX: number
positionY: number
teleport?: {
toMapId: UUID
toPositionX: number
toPositionY: number
toRotation: number
}
}[]
mapEffects: {
effect: string
strength: number
}[]
placedMapObjects: PlacedMapObject[]
}
export default class MapUpdateEvent extends BaseEvent {
public listen(): void {
this.socket.on('gm:map:update', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Map | null) => void): Promise<void> {
try {
if (!(await this.isCharacterGM())) return
const character = await this.getCharacter()
this.logger.info(`User ${character!.getId()} has updated map via map editor.`)
if (!data.mapId) {
this.logger.info(`User ${character!.getId()} tried to update map but did not provide a map id.`)
return callback(null)
}
let map = await MapRepository.getById(data.mapId)
if (!map) {
this.logger.info(`User ${character!.getId()} tried to update map ${data.mapId} but it does not exist.`)
return callback(null)
}
// Validation logic remains the same
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)
}
}
data.mapEventTiles = data.mapEventTiles.filter((tile) => tile.positionX >= 0 && tile.positionX < data.width && tile.positionY >= 0 && tile.positionY < data.height)
data.placedMapObjects = data.placedMapObjects.filter((obj) => obj.positionX >= 0 && obj.positionX < data.width && obj.positionY >= 0 && obj.positionY < data.height)
// Clear existing collections
map.mapEventTiles.removeAll()
map.placedMapObjects.removeAll()
map.mapEffects.removeAll()
// Create and add new map event tiles
for (const tile of data.mapEventTiles) {
const mapEventTile = new MapEventTile().setType(tile.type).setPositionX(tile.positionX).setPositionY(tile.positionY).setMap(map)
if (tile.teleport) {
const teleport = new MapEventTileTeleport()
.setToMap((await MapRepository.getById(tile.teleport.toMapId))!)
.setToPositionX(tile.teleport.toPositionX)
.setToPositionY(tile.teleport.toPositionY)
.setToRotation(tile.teleport.toRotation)
mapEventTile.setTeleport(teleport)
}
map.mapEventTiles.add(mapEventTile)
}
// Create and add new map objects
for (const object of data.placedMapObjects) {
const mapObject = new PlacedMapObject().setMapObject(object.mapObject).setDepth(object.depth).setIsRotated(object.isRotated).setPositionX(object.positionX).setPositionY(object.positionY).setMap(map)
map.placedMapObjects.add(mapObject)
}
// Create and add new map effects
for (const effect of data.mapEffects) {
const mapEffect = new MapEffect().setEffect(effect.effect).setStrength(effect.strength).setMap(map)
map.mapEffects.add(mapEffect)
}
// Update map properties
await map.setName(data.name).setWidth(data.width).setHeight(data.height).setTiles(data.tiles).setPvp(data.pvp).setUpdatedAt(new Date()).update()
// Reload map from database to get fresh data
map = await MapRepository.getById(data.mapId)
if (!map) {
this.logger.info(`User ${character!.getId()} tried to update map ${data.mapId} but it does not exist after update.`)
return callback(null)
}
// Reload map for players
mapManager.unloadMap(data.mapId)
await mapManager.loadMap(map)
return callback(map)
} catch (error: any) {
this.logger.error(`gm:mapObject:update error: ${error instanceof Error ? error.message : String(error)}`)
return callback(null)
}
}
}

View File

@ -1,63 +0,0 @@
import { Zone } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import ZoneRepository from '#repositories/zoneRepository'
type Payload = {
name: string
width: number
height: number
}
export default class ZoneCreateEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:zone_editor:zone:create', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: Zone[]) => void): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:create error', 'Character not found')
callback([])
return
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to create zone but is not a game master.`)
callback([])
return
}
gameMasterLogger.info(`User ${character.id} has created a new zone via zone editor.`)
const zone = await prisma.zone.create({
data: {
name: data.name,
width: data.width,
height: data.height,
tiles: Array.from({ length: data.height }, () => Array.from({ length: data.width }, () => 'blank_tile'))
}
})
const zoneList = await ZoneRepository.getAll()
callback(zoneList)
// You might want to emit an event to notify other clients about the new zone
// this.io.emit('gm:zone_created', zone);
} catch (error: any) {
gameMasterLogger.error('gm:zone_editor:zone:create error', error.message)
this.socket.emit('notification', { message: 'Failed to create zone.' })
callback([])
}
}
}

View File

@ -1,62 +0,0 @@
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import prisma from '#application/prisma'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import ZoneRepository from '#repositories/zoneRepository'
type Payload = {
zoneId: number
}
export default class ZoneDeleteEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:zone_editor:zone:delete', this.handleEvent.bind(this))
}
private async handleEvent(data: Payload, callback: (response: boolean) => void): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:delete error', 'Character not found')
callback(false)
return
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to delete zone but is not a game master.`)
callback(false)
return
}
gameMasterLogger.info(`User ${character.id} has deleted a zone via zone editor.`)
const zone = await ZoneRepository.getById(data.zoneId)
if (!zone) {
gameMasterLogger.error('gm:zone_editor:zone:delete error', 'Zone not found')
callback(false)
return
}
await prisma.zone.delete({
where: {
id: data.zoneId
}
})
callback(true)
// You might want to emit an event to notify other clients about the deleted zone
// this.io.emit('gm:zone_deleted', data.zoneId);
} catch (error: any) {
gameMasterLogger.error('gm:zone_editor:zone:delete error', error.message)
callback(false)
}
}
}

View File

@ -1,45 +0,0 @@
import { Zone } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import ZoneRepository from '#repositories/zoneRepository'
interface IPayload {}
export default class ZoneListEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:zone_editor:zone:list', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Zone[]) => void): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:list error', 'Character not found')
callback([])
return
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to list zones but is not a game master.`)
callback([])
return
}
gameMasterLogger.info(`User ${character.id} has requested zone list via zone editor.`)
const zones = await ZoneRepository.getAll()
callback(zones)
} catch (error: any) {
gameMasterLogger.error('gm:zone_editor:zone:list error', error.message)
callback([])
}
}
}

View File

@ -1,60 +0,0 @@
import { Zone } from '@prisma/client'
import { Server } from 'socket.io'
import { gameMasterLogger } from '#application/logger'
import { TSocket } from '#application/types'
import CharacterRepository from '#repositories/characterRepository'
import ZoneRepository from '#repositories/zoneRepository'
interface IPayload {
zoneId: number
}
export default class ZoneRequestEvent {
constructor(
private readonly io: Server,
private readonly socket: TSocket
) {}
public listen(): void {
this.socket.on('gm:zone_editor:zone:request', this.handleEvent.bind(this))
}
private async handleEvent(data: IPayload, callback: (response: Zone | null) => void): Promise<void> {
try {
const character = await CharacterRepository.getById(this.socket.characterId as number)
if (!character) {
gameMasterLogger.error('gm:zone_editor:zone:request error', 'Character not found')
callback(null)
return
}
if (character.role !== 'gm') {
gameMasterLogger.info(`User ${character.id} tried to request zone but is not a game master.`)
callback(null)
return
}
gameMasterLogger.info(`User ${character.id} has requested zone via zone editor.`)
if (!data.zoneId) {
gameMasterLogger.info(`User ${character.id} tried to request zone but did not provide a zone id.`)
callback(null)
return
}
const zone = await ZoneRepository.getById(data.zoneId)
if (!zone) {
gameMasterLogger.info(`User ${character.id} tried to request zone ${data.zoneId} but it does not exist.`)
callback(null)
return
}
callback(zone)
} catch (error: any) {
gameMasterLogger.error('gm:zone_editor:zone:request error', error.message)
callback(null)
}
}
}

View File

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

View File

@ -0,0 +1,104 @@
import { BaseEvent } from '#application/base/baseEvent'
import { MapEventTileWithTeleport } from '#application/types'
import MapManager from '#managers/mapManager'
import MapCharacter from '#models/mapCharacter'
import mapEventTileRepository from '#repositories/mapEventTileRepository'
import CharacterService from '#services/characterService'
import MapEventTileService from '#services/mapEventTileService'
export default class CharacterMove extends BaseEvent {
private readonly characterService = CharacterService
private readonly mapEventTileService = MapEventTileService
public listen(): void {
this.socket.on('map:character:move', this.handleEvent.bind(this))
}
private async handleEvent({ positionX, positionY }: { positionX: number; positionY: number }): Promise<void> {
const mapCharacter = MapManager.getCharacterById(this.socket.characterId!)
if (!mapCharacter?.character) {
this.logger.error('map:character:move error: Character not found or not initialized')
return
}
// If already moving, cancel current movement and wait for it to fully stop
if (mapCharacter.isMoving) {
mapCharacter.isMoving = false
await new Promise((resolve) => setTimeout(resolve, 100))
}
const path = await this.characterService.calculatePath(mapCharacter.character, positionX, positionY)
if (!path) {
this.io.in(mapCharacter.character.map.id).emit('map:character:moveError', 'No valid path found')
return
}
// Start new movement
mapCharacter.isMoving = true
mapCharacter.currentPath = path // Add this property to MapCharacter class
await this.moveAlongPath(mapCharacter, path)
}
private async moveAlongPath(mapCharacter: MapCharacter, path: Array<{ x: number; y: number }>): Promise<void> {
const { character } = mapCharacter
for (let i = 0; i < path.length - 1; i++) {
if (!mapCharacter.isMoving || mapCharacter.currentPath !== path) {
return
}
const [start, end] = [path[i], path[i + 1]]
character.rotation = CharacterService.calculateRotation(start.x, start.y, end.x, end.y)
const mapEventTile = await mapEventTileRepository.getEventTileByMapIdAndPosition(character.map.id, Math.floor(end.x), Math.floor(end.y))
if (mapEventTile?.type === 'BLOCK') break
if (mapEventTile?.type === 'TELEPORT' && mapEventTile.teleport) {
await this.handleMapEventTile(mapEventTile as MapEventTileWithTeleport)
break
}
// Update position first
character.positionX = end.x
character.positionY = end.y
// Then emit with the same properties
this.io.in(character.map.id).emit('map:character:move', {
characterId: character.id,
positionX: character.positionX,
positionY: character.positionY,
rotation: character.rotation,
isMoving: true
})
await this.characterService.applyMovementDelay()
}
if (mapCharacter.isMoving && mapCharacter.currentPath === path) {
this.finalizeMovement(mapCharacter)
}
}
private async handleMapEventTile(mapEventTile: MapEventTileWithTeleport): Promise<void> {
const mapCharacter = MapManager.getCharacterById(this.socket.characterId!)
if (!mapCharacter) {
this.logger.error('map:character:move error: Character not found')
return
}
if (mapEventTile.teleport) {
await this.mapEventTileService.handleTeleport(this.io, this.socket, mapCharacter.character, mapEventTile.teleport)
}
}
private finalizeMovement(mapCharacter: MapCharacter): void {
mapCharacter.isMoving = false
this.io.in(mapCharacter.character.map.id).emit('map:character:move', {
characterId: mapCharacter.character.id,
positionX: mapCharacter.character.positionX,
positionY: mapCharacter.character.positionY,
rotation: mapCharacter.character.rotation,
isMoving: false
})
}
}

View File

@ -24,12 +24,12 @@ export default class CharacterMove extends BaseEvent {
// 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))
await new Promise((resolve) => setTimeout(resolve, 50))
}
const path = await this.characterService.calculatePath(zoneCharacter.character, positionX, positionY)
if (!path) {
this.io.in(zoneCharacter.character.zone!.id.toString()).emit('zone:character:moveError', 'No valid path found')
this.io.in(zoneCharacter.character.zone.id).emit('zone:character:moveError', 'No valid path found')
return
}
@ -42,40 +42,44 @@ export default class CharacterMove extends BaseEvent {
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++) {
if (!zoneCharacter.isMoving || zoneCharacter.currentPath !== path) {
return
try {
for (let i = 0; i < path.length - 1; i++) {
if (!zoneCharacter.isMoving || zoneCharacter.currentPath !== path) {
return
}
const [start, end] = [path[i], path[i + 1]]
character.rotation = CharacterService.calculateRotation(start.x, start.y, end.x, end.y)
const zoneEventTile = await zoneEventTileRepository.getEventTileByZoneIdAndPosition(
character.zone.id,
Math.floor(end.x),
Math.floor(end.y)
)
if (zoneEventTile?.type === 'BLOCK') break
if (zoneEventTile?.type === 'TELEPORT' && zoneEventTile.teleport) {
await this.handleZoneEventTile(zoneEventTile as ZoneEventTileWithTeleport)
break
}
character.positionX = end.x
character.positionY = end.y
this.io.in(character.zone.id).emit('zone:character:move', {
characterId: character.id,
positionX: character.positionX,
positionY: character.positionY,
rotation: character.rotation,
isMoving: true
})
await this.characterService.applyMovementDelay()
}
const [start, end] = [path[i], path[i + 1]]
character.rotation = CharacterService.calculateRotation(start.x, start.y, end.x, end.y)
const zoneEventTile = await zoneEventTileRepository.getEventTileByZoneIdAndPosition(character.zone!.id, 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
} finally {
if (zoneCharacter.isMoving && zoneCharacter.currentPath === path) {
this.finalizeMovement(zoneCharacter)
}
// Update position first
character.positionX = end.x
character.positionY = end.y
// Then emit with the same properties
this.io.in(character.zone!.id.toString()).emit('zone:character:move', {
id: character.id,
positionX: character.positionX,
positionY: character.positionY,
rotation: character.rotation,
isMoving: true
})
await this.characterService.applyMovementDelay()
}
if (zoneCharacter.isMoving && zoneCharacter.currentPath === path) {
this.finalizeMovement(zoneCharacter)
}
}
@ -93,8 +97,8 @@ export default class CharacterMove extends BaseEvent {
private finalizeMovement(zoneCharacter: ZoneCharacter): void {
zoneCharacter.isMoving = false
this.io.in(zoneCharacter.character.zone!.id.toString()).emit('zone:character:move', {
id: zoneCharacter.character.id,
this.io.in(zoneCharacter.character.zone.id).emit('zone:character:move', {
characterId: zoneCharacter.character.id,
positionX: zoneCharacter.character.positionX,
positionY: zoneCharacter.character.positionY,
rotation: zoneCharacter.character.rotation,

View File

@ -3,11 +3,12 @@ import fs from 'fs'
import { Request, Response } from 'express'
import { BaseController } from '#application/base/baseController'
import { getPublicPath } from '#application/storage'
import Database from '#application/database'
import Storage from '#application/storage'
import { AssetData, UUID } from '#application/types'
import SpriteRepository from '#repositories/spriteRepository'
import TileRepository from '#repositories/tileRepository'
import ZoneRepository from '#repositories/zoneRepository'
import MapRepository from '#repositories/mapRepository'
export class AssetsController extends BaseController {
/**
@ -27,24 +28,24 @@ export class AssetsController extends BaseController {
}
/**
* List tiles by zone
* List tiles by map
* @param req
* @param res
*/
public async listTilesByZone(req: Request, res: Response) {
const zoneId = parseInt(req.params.zoneId)
public async listTilesByMap(req: Request, res: Response) {
const mapId = req.params.mapId as UUID
if (!zoneId || zoneId === 0) {
return this.sendError(res, 'Invalid zone ID', 400)
if (!mapId) {
return this.sendError(res, 'Invalid map ID', 400)
}
const zone = await ZoneRepository.getById(zoneId)
if (!zone) {
return this.sendError(res, 'Zone not found', 404)
const map = await MapRepository.getById(mapId)
if (!map) {
return this.sendError(res, 'Map not found', 404)
}
const assets: AssetData[] = []
const tiles = await TileRepository.getByZoneId(zoneId)
const tiles = await TileRepository.getByMapId(mapId)
for (const tile of tiles) {
assets.push({ key: tile.getId(), data: '/assets/tiles/' + tile.getId() + '.png', group: 'tiles', updatedAt: tile.getUpdatedAt() } as AssetData)
@ -70,18 +71,20 @@ export class AssetsController extends BaseController {
return this.sendError(res, 'Sprite not found', 404)
}
await Database.getEntityManager().populate(sprite, ['spriteActions'])
const assets: AssetData[] = sprite.spriteActions.getItems().map((spriteAction) => ({
key: sprite.id + '-' + spriteAction.action,
key: sprite.getId() + '-' + spriteAction.getAction(),
data: '/assets/sprites/' + sprite.getId() + '/' + spriteAction.getAction() + '.png',
group: spriteAction.isAnimated ? 'sprite_animations' : 'sprites',
group: spriteAction.getIsAnimated() ? 'sprite_animations' : 'sprites',
updatedAt: sprite.getUpdatedAt(),
originX: Number(spriteAction.originX.toString()),
originY: Number(spriteAction.originY.toString()),
originX: Number(spriteAction.getOriginX().toString()),
originY: Number(spriteAction.getOriginY().toString()),
isAnimated: spriteAction.getIsAnimated(),
frameRate: spriteAction.getFrameRate(),
frameWidth: spriteAction.getFrameWidth(),
frameHeight: spriteAction.getFrameHeight(),
frameCount: JSON.parse(JSON.stringify(spriteAction.getSprites())).length
frameCount: spriteAction.getSprites()?.length
}))
return this.sendSuccess(res, assets)
@ -95,7 +98,7 @@ export class AssetsController extends BaseController {
public async downloadAsset(req: Request, res: Response) {
const { type, spriteId, file } = req.params
const assetPath = type === 'sprites' && spriteId ? getPublicPath(type, spriteId, file) : getPublicPath(type, file)
const assetPath = type === 'sprites' && spriteId ? Storage.getPublicPath(type, spriteId, file) : Storage.getPublicPath(type, file)
if (!fs.existsSync(assetPath)) {
this.logger.error(`File not found: ${assetPath}`)

View File

@ -4,14 +4,15 @@ import { Request, Response } from 'express'
import sharp from 'sharp'
import { BaseController } from '#application/base/baseController'
import { getPublicPath } from '#application/storage'
import Storage from '#application/storage'
import { UUID } from '#application/types'
import CharacterHairRepository from '#repositories/characterHairRepository'
import CharacterRepository from '#repositories/characterRepository'
import CharacterTypeRepository from '#repositories/characterTypeRepository'
interface AvatarOptions {
characterTypeId: number
characterHairId?: number
characterTypeId: UUID
characterHairId?: UUID
}
export class AvatarController extends BaseController {
@ -39,8 +40,8 @@ export class AvatarController extends BaseController {
*/
public async getByParams(req: Request, res: Response) {
return this.generateAvatar(res, {
characterTypeId: parseInt(req.params.characterTypeId),
characterHairId: req.params.characterHairId ? parseInt(req.params.characterHairId) : undefined
characterTypeId: req.params.characterTypeId as UUID,
characterHairId: req.params.characterHairId ? (req.params.characterHairId as UUID) : undefined
})
}
@ -57,7 +58,7 @@ export class AvatarController extends BaseController {
return this.sendError(res, 'Character type not found', 404)
}
const bodySpritePath = getPublicPath('sprites', characterType.sprite.id, 'idle_right_down.png')
const bodySpritePath = Storage.getPublicPath('sprites', characterType.sprite.id, 'idle_right_down.png')
if (!fs.existsSync(bodySpritePath)) {
return this.sendError(res, 'Body sprite file not found', 404)
}
@ -71,7 +72,7 @@ export class AvatarController extends BaseController {
if (options.characterHairId) {
const characterHair = await CharacterHairRepository.getById(options.characterHairId)
if (characterHair?.sprite?.id) {
const hairSpritePath = getPublicPath('sprites', characterHair.sprite.id, 'front.png')
const hairSpritePath = Storage.getPublicPath('sprites', characterHair.sprite.id, 'front.png')
if (fs.existsSync(hairSpritePath)) {
avatar = avatar.composite([{ input: hairSpritePath, gravity: 'north' }])
}

View File

@ -11,7 +11,6 @@ export class ConsoleManager {
private readonly registry: CommandRegistry
private readonly prompt: ConsolePrompt
private readonly logReader: LogReader
private io: Server | null = null
constructor() {
this.registry = new CommandRegistry()
@ -21,8 +20,6 @@ export class ConsoleManager {
}
public async boot(): Promise<void> {
this.io = SocketManager.getIO()
await this.registry.loadCommands()
this.logReader.start()
this.prompt.start()
@ -45,7 +42,7 @@ export class ConsoleManager {
}
try {
const commandInstance = new CommandClass(this.io as Server)
const commandInstance = new CommandClass(SocketManager.getIO())
await commandInstance.execute(args)
} catch (error) {
this.logger.error(`Error executing command ${cmd}: ${error instanceof Error ? error.message : String(error)}`)

View File

@ -1,75 +1,84 @@
import { Server } from 'socket.io'
import Logger, { LoggerType } from '#application/logger'
import SocketManager from '#managers/socketManager'
import worldRepository from '#repositories/worldRepository'
import worldService from '#services/worldService'
class DateManager {
private static readonly GAME_SPEED = 8 // 24 game hours / 3 real hours
private static readonly UPDATE_INTERVAL = 1000 // 1 second
private static readonly CONFIG = {
GAME_SPEED: 8, // 24 game hours / 3 real hours
UPDATE_INTERVAL: 1000 // 1 second
} as const
private io: Server | null = null
private intervalId: NodeJS.Timeout | null = null
private currentDate: Date = new Date()
private logger = Logger.type(LoggerType.APP)
private currentDate = new Date()
private readonly logger = Logger.type(LoggerType.APP)
public async boot(io: Server): Promise<void> {
this.io = io
public async boot(): Promise<void> {
this.io = SocketManager.getIO()
await this.loadDate()
this.startDateLoop()
this.logger.info('Date manager loaded')
}
public async setTime(time: string): Promise<void> {
try {
let newDate: Date
public getCurrentDate(): Date {
return this.currentDate
}
// 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
}
public async setTime(timeString: string): Promise<void> {
try {
const newDate = this.parseTimeString(timeString)
if (!newDate) return
this.currentDate = newDate
this.emitDate()
await this.saveDate()
} catch (error) {
this.logger.error(`Failed to set time: ${error instanceof Error ? error.message : String(error)}`)
this.handleError('Failed to set time', error)
throw error
}
}
public cleanup(): void {
this.intervalId && clearInterval(this.intervalId)
}
private async loadDate(): Promise<void> {
try {
const world = await worldRepository.getFirst()
if (world) {
this.currentDate = world.date
}
this.currentDate = world?.date ?? new Date()
} catch (error) {
this.logger.error(`Failed to load date: ${error instanceof Error ? error.message : String(error)}`)
this.currentDate = new Date() // Use current date as fallback
this.handleError('Failed to load date', error)
this.currentDate = new Date()
}
}
private parseTimeString(timeString: string): Date | null {
const timeOnlyPattern = /^\d{1,2}:\d{2}(:\d{2})?$/
if (timeOnlyPattern.test(timeString)) {
const [hours, minutes] = timeString.split(':').map(Number)
const newDate = new Date(this.currentDate)
newDate.setHours(hours, minutes)
return newDate
}
const fullDate = new Date(timeString)
return isNaN(fullDate.getTime()) ? null : fullDate
}
private startDateLoop(): void {
this.intervalId = setInterval(() => {
this.advanceGameTime()
this.emitDate()
void this.saveDate()
}, DateManager.UPDATE_INTERVAL)
}, DateManager.CONFIG.UPDATE_INTERVAL)
}
private advanceGameTime(): void {
if (!this.currentDate) {
this.currentDate = new Date()
}
const advanceMilliseconds = DateManager.GAME_SPEED * DateManager.UPDATE_INTERVAL
const advanceMilliseconds = DateManager.CONFIG.GAME_SPEED * DateManager.CONFIG.UPDATE_INTERVAL
this.currentDate = new Date(this.currentDate.getTime() + advanceMilliseconds)
}
@ -79,22 +88,14 @@ class DateManager {
private async saveDate(): Promise<void> {
try {
await worldService.update({
date: this.currentDate
})
await worldService.update({ date: this.currentDate })
} catch (error) {
this.logger.error(`Failed to save date: ${error instanceof Error ? error.message : String(error)}`)
this.handleError('Failed to save date', error)
}
}
public cleanup(): void {
if (this.intervalId) {
clearInterval(this.intervalId)
}
}
public getCurrentDate(): Date {
return this.currentDate
private handleError(message: string, error: unknown): void {
this.logger.error(`${message}: ${error instanceof Error ? error.message : String(error)}`)
}
}

View File

@ -33,7 +33,7 @@ class HttpManager {
// Assets routes
app.get('/assets/list_tiles', (req, res) => this.assetsController.listTiles(req, res))
app.get('/assets/list_tiles/:zoneId', (req, res) => this.assetsController.listTilesByZone(req, res))
app.get('/assets/list_tiles/:mapId', (req, res) => this.assetsController.listTilesByMap(req, res))
app.get('/assets/list_sprite_actions/:spriteId', (req, res) => this.assetsController.listSpriteActions(req, res))
app.get('/assets/:type/:spriteId?/:file', (req, res) => this.assetsController.downloadAsset(req, res))
}

View File

@ -0,0 +1,50 @@
import Logger, { LoggerType } from '#application/logger'
import { UUID } from '#application/types'
import { Map } from '#entities/map'
import LoadedMap from '#models/loadedMap'
import MapCharacter from '#models/mapCharacter'
import MapRepository from '#repositories/mapRepository'
class MapManager {
private readonly maps: Record<UUID, LoadedMap> = {}
private logger = Logger.type(LoggerType.GAME)
public async boot(): Promise<void> {
const maps = await MapRepository.getAll()
await Promise.all(maps.map((map) => this.loadMap(map)))
this.logger.info(`Map manager loaded with ${Object.keys(this.maps).length} maps`)
}
public async loadMap(map: Map): Promise<void> {
this.maps[map.id] = new LoadedMap(map)
this.logger.info(`Map ID ${map.id} loaded`)
}
public unloadMap(mapId: UUID): void {
delete this.maps[mapId]
this.logger.info(`Map ID ${mapId} unloaded`)
}
public getLoadedMaps(): LoadedMap[] {
return Object.values(this.maps)
}
public getMapById(mapId: UUID): LoadedMap | undefined {
return this.maps[mapId]
}
public getCharacterById(characterId: UUID): MapCharacter | undefined {
for (const map of Object.values(this.maps)) {
const character = map.getCharactersInMap().find((char) => char.character.id === characterId)
if (character) return character
}
return undefined
}
public removeCharacter(characterId: UUID): void {
Object.values(this.maps).forEach((map) => map.removeCharacter(characterId))
}
}
export default new MapManager()

View File

@ -6,7 +6,7 @@ import { Server as SocketServer } from 'socket.io'
import config from '#application/config'
import Logger, { LoggerType } from '#application/logger'
import { getAppPath } from '#application/storage'
import Storage from '#application/storage'
import { TSocket } from '#application/types'
import SocketManager from '#managers/socketManager'
@ -56,9 +56,9 @@ class QueueManager {
const { jobName, params, socketId } = job.data
try {
const jobsDir = getAppPath('jobs')
const jobsDir = Storage.getAppPath('jobs')
const extension = config.ENV === 'development' ? '.ts' : '.js'
const jobPath = getAppPath('jobs', `${jobName}${extension}`)
const jobPath = Storage.getAppPath('jobs', `${jobName}${extension}`)
if (!fs.existsSync(jobPath)) {
this.logger.warn(`Job file not found: ${jobPath}`)

View File

@ -1,13 +1,14 @@
import { Server as SocketServer } from 'socket.io'
import fs from 'fs'
import { pathToFileURL } from 'url'
import { Server as HTTPServer } from 'http'
import { Application } from 'express'
import { pathToFileURL } from 'url'
import { Application } from 'express'
import { Server as SocketServer } from 'socket.io'
import Logger, { LoggerType } from '#application/logger'
import { getAppPath } from '#application/storage'
import { TSocket } from '#application/types'
import config from '#application/config'
import Logger, { LoggerType } from '#application/logger'
import Storage from '#application/storage'
import { TSocket, UUID } from '#application/types'
import { Authentication } from '#middleware/authentication'
class SocketManager {
@ -60,11 +61,11 @@ class SocketManager {
*/
private async loadEventHandlers(baseDir: string, subDir: string, socket: TSocket): Promise<void> {
try {
const fullDir = getAppPath(baseDir, subDir)
const fullDir = Storage.getAppPath(baseDir, subDir)
const files = await fs.promises.readdir(fullDir, { withFileTypes: true })
for (const file of files) {
const filePath = getAppPath(baseDir, subDir, file.name)
const filePath = Storage.getAppPath(baseDir, subDir, file.name)
if (file.isDirectory()) {
await this.loadEventHandlers(baseDir, `${subDir}/${file.name}`, socket)
@ -105,8 +106,20 @@ class SocketManager {
* Emit event to specific room
*/
public emitToRoom(room: string, event: string, ...args: any[]): void {
this.getIO().to(room).emit(event, ...args)
this.getIO()
.to(room)
.emit(event, ...args)
}
public getSocketByUserId(userId: UUID): TSocket | undefined {
const sockets = Array.from(this.getIO().sockets.sockets.values())
return sockets.find((socket: TSocket) => socket.userId === userId)
}
public getSocketByCharacterId(characterId: UUID): TSocket | undefined {
const sockets = Array.from(this.getIO().sockets.sockets.values())
return sockets.find((socket: TSocket) => socket.characterId === characterId)
}
}
export default new SocketManager()
export default new SocketManager()

View File

@ -1,10 +1,11 @@
import { Server } from 'socket.io'
import Logger, { LoggerType } from '#application/logger'
import SocketManager from '#managers/socketManager'
import worldRepository from '#repositories/worldRepository'
import worldService from '#services/worldService'
interface WeatherState {
type WeatherState = {
isRainEnabled: boolean
rainPercentage: number
isFogEnabled: boolean
@ -12,13 +13,18 @@ interface WeatherState {
}
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 readonly logger = Logger.type(LoggerType.APP)
private static readonly CONFIG = {
UPDATE_INTERVAL_MS: 60_000, // Check weather every minute
RAIN_CHANCE: 0.2, // 20% chance
FOG_CHANCE: 0.15, // 15% chance
RAIN_PERCENTAGE_RANGE: { min: 50, max: 100 },
FOG_DENSITY_RANGE: { min: 30, max: 100 }
} as const
private readonly logger = Logger.type(LoggerType.APP)
private io: Server | null = null
private intervalId: NodeJS.Timeout | null = null
private weatherState: WeatherState = {
isRainEnabled: false,
rainPercentage: 0,
@ -26,37 +32,34 @@ class WeatherManager {
fogDensity: 0
}
public async boot(io: Server): Promise<void> {
// this.io = io
// await this.loadWeather()
// this.startWeatherLoop()
public async boot(): Promise<void> {
this.io = SocketManager.getIO()
await this.loadWeather()
this.startWeatherLoop()
this.logger.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
public getWeatherState(): WeatherState {
return { ...this.weatherState }
}
await this.saveWeather()
this.emitWeather()
public async toggleRain(): Promise<void> {
this.updateWeatherProperty('rain')
await this.saveAndEmitWeather()
}
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
this.updateWeatherProperty('fog')
await this.saveAndEmitWeather()
}
await this.saveWeather()
this.emitWeather()
public cleanup(): void {
this.intervalId && clearInterval(this.intervalId)
}
private async loadWeather(): Promise<void> {
try {
const world = await worldRepository.getFirst()
if (world) {
this.weatherState = {
isRainEnabled: world.isRainEnabled,
@ -66,63 +69,61 @@ class WeatherManager {
}
}
} catch (error) {
this.logger.error(`Failed to load weather: ${error instanceof Error ? error.message : String(error)}`)
this.logError('load', error)
}
}
public getWeatherState(): WeatherState {
return this.weatherState
}
private startWeatherLoop(): void {
this.intervalId = setInterval(async () => {
this.updateWeather()
this.emitWeather()
await this.saveWeather().catch((error) => {
this.logger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
})
}, WeatherManager.UPDATE_INTERVAL)
this.updateRandomWeather()
await this.saveAndEmitWeather()
}, WeatherManager.CONFIG.UPDATE_INTERVAL_MS)
}
private updateWeather(): void {
// Update rain
if (Math.random() < WeatherManager.RAIN_CHANCE) {
private updateRandomWeather(): void {
if (Math.random() < WeatherManager.CONFIG.RAIN_CHANCE) {
this.updateWeatherProperty('rain')
}
if (Math.random() < WeatherManager.CONFIG.FOG_CHANCE) {
this.updateWeatherProperty('fog')
}
}
private updateWeatherProperty(type: 'rain' | 'fog'): void {
if (type === 'rain') {
this.weatherState.isRainEnabled = !this.weatherState.isRainEnabled
this.weatherState.rainPercentage = this.weatherState.isRainEnabled
? Math.floor(Math.random() * 50) + 50 // 50-100%
: 0
this.weatherState.rainPercentage = this.weatherState.isRainEnabled ? this.getRandomNumber(WeatherManager.CONFIG.RAIN_PERCENTAGE_RANGE.min, WeatherManager.CONFIG.RAIN_PERCENTAGE_RANGE.max) : 0
}
// Update fog
if (Math.random() < WeatherManager.FOG_CHANCE) {
if (type === 'fog') {
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
this.weatherState.fogDensity = this.weatherState.isFogEnabled ? this.getRandomNumber(WeatherManager.CONFIG.FOG_DENSITY_RANGE.min, WeatherManager.CONFIG.FOG_DENSITY_RANGE.max) : 0
}
}
private async saveAndEmitWeather(): Promise<void> {
await this.saveWeather()
this.emitWeather()
}
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
})
await worldService.update(this.weatherState)
} catch (error) {
this.logger.error(`Failed to save weather: ${error instanceof Error ? error.message : String(error)}`)
this.logError('save', error)
}
}
public cleanup(): void {
if (this.intervalId) {
clearInterval(this.intervalId)
}
private getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
private logError(operation: string, error: unknown): void {
this.logger.error(`Failed to ${operation} weather: ${error instanceof Error ? error.message : String(error)}`)
}
}

View File

@ -1,50 +0,0 @@
import Logger, { LoggerType } from '#application/logger'
import { Zone } from '#entities/zone'
import LoadedZone from '#models/loadedZone'
import ZoneCharacter from '#models/zoneCharacter'
import ZoneRepository from '#repositories/zoneRepository'
class ZoneManager {
private readonly zones = new Map<number, LoadedZone>()
private logger = Logger.type(LoggerType.GAME)
public async boot(): Promise<void> {
const zones = await ZoneRepository.getAll()
await Promise.all(zones.map((zone) => this.loadZone(zone)))
this.logger.info(`Zone manager loaded with ${this.zones.size} zones`)
}
public async loadZone(zone: Zone): Promise<void> {
const loadedZone = new LoadedZone(zone)
this.zones.set(zone.id, loadedZone)
this.logger.info(`Zone ID ${zone.id} loaded`)
}
public unloadZone(zoneId: number): void {
this.zones.delete(zoneId)
this.logger.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 getCharacterById(characterId: number): ZoneCharacter | undefined {
for (const zone of this.zones.values()) {
const character = zone.getCharactersInZone().find((char) => char.character.id === characterId)
if (character) return character
}
return undefined
}
public removeCharacter(characterId: number): void {
this.zones.forEach((zone) => zone.removeCharacter(characterId))
}
}
export default new ZoneManager()

58
src/models/loadedMap.ts Normal file
View File

@ -0,0 +1,58 @@
import MapCharacter from './mapCharacter'
import { UUID } from '#application/types'
import { Character } from '#entities/character'
import { Map } from '#entities/map'
import mapEventTileRepository from '#repositories/mapEventTileRepository'
class LoadedMap {
private readonly map: Map
private characters: MapCharacter[] = []
constructor(map: Map) {
this.map = map
}
public getMap(): Map {
return this.map
}
public addCharacter(character: Character) {
const mapCharacter = new MapCharacter(character)
this.characters.push(mapCharacter)
}
public async removeCharacter(id: UUID) {
const mapCharacter = this.getCharacterById(id)
if (mapCharacter) {
await mapCharacter.savePosition()
this.characters = this.characters.filter((c) => c.character.id !== id)
}
}
public getCharacterById(id: UUID): MapCharacter | undefined {
return this.characters.find((c) => c.character.id === id)
}
public getCharactersInMap(): MapCharacter[] {
console.log(this.characters)
return this.characters
}
public async getGrid(): Promise<number[][]> {
let grid: number[][] = Array.from({ length: this.map.height }, () => Array.from({ length: this.map.width }, () => 0))
const eventTiles = await mapEventTileRepository.getAll(this.map.id)
// Set the grid values based on the event tiles, these are strings
eventTiles.forEach((eventTile) => {
if (eventTile.type === 'BLOCK') {
grid[eventTile.positionY][eventTile.positionX] = 1
}
})
return grid
}
}
export default LoadedMap

View File

@ -1,57 +0,0 @@
import ZoneCharacter from './zoneCharacter'
import { Character } from '#entities/character'
import { Zone } from '#entities/zone'
import zoneEventTileRepository from '#repositories/zoneEventTileRepository'
class LoadedZone {
private readonly zone: Zone
private characters: ZoneCharacter[] = []
constructor(zone: Zone) {
this.zone = zone
}
public getZone(): 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[] {
console.log(this.characters)
return this.characters
}
public async getGrid(): Promise<number[][]> {
let grid: number[][] = Array.from({ length: this.zone.height }, () => Array.from({ length: this.zone.width }, () => 0))
const eventTiles = await zoneEventTileRepository.getAll(this.zone.id)
// Set the grid values based on the event tiles, these are strings
eventTiles.forEach((eventTile) => {
if (eventTile.type === 'BLOCK') {
grid[eventTile.positionY][eventTile.positionX] = 1
}
})
return grid
}
}
export default LoadedZone

View File

@ -0,0 +1,54 @@
import { Server } from 'socket.io'
import { TSocket } from '#application/types'
import { Character } from '#entities/character'
import SocketManager from '#managers/socketManager'
import MapManager from '#managers/mapManager'
import TeleportService from '#services/teleportService'
class MapCharacter {
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() {
await this.character.setPositionX(this.character.positionX).setPositionY(this.character.positionY).setRotation(this.character.rotation).setMap(this.character.map).update()
}
public async teleport(mapId: number, targetX: number, targetY: number): Promise<void> {
await TeleportService.teleportCharacter(this.character.id, {
targetMapId: mapId,
targetX,
targetY
})
}
public async disconnect(socket: TSocket, io: Server): Promise<void> {
try {
// Stop any movement and save final position
this.isMoving = false
this.currentPath = null
await this.savePosition()
// Leave map and remove from manager
if (this.character.map) {
socket.leave(this.character.map.id)
MapManager.removeCharacter(this.character.id)
// Notify map players
io.in(this.character.map.id).emit('map:character:leave', this.character.id)
}
// Notify all players
io.emit('character:disconnect', this.character.id)
} catch (error) {
console.error(`Error disconnecting character ${this.character.id}:`, error)
}
}
}
export default MapCharacter

View File

@ -1,17 +0,0 @@
import { Character } from '#entities/character'
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() {
await this.character.setPositionX(this.character.positionX).setPositionY(this.character.positionY).setRotation(this.character.rotation).setZone(this.character.zone).update()
}
}
export default ZoneCharacter

View File

@ -1,4 +1,5 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { CharacterHair } from '#entities/characterHair'
class CharacterHairRepository extends BaseRepository {
@ -32,7 +33,7 @@ class CharacterHairRepository extends BaseRepository {
}
}
async getById(id: number): Promise<CharacterHair | null> {
async getById(id: UUID): Promise<CharacterHair | null> {
try {
const repository = this.em.getRepository(CharacterHair)
return await repository.findOne({ id })

View File

@ -1,18 +1,20 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { Character } from '#entities/character'
import { LoadHint, Populate } from '@mikro-orm/core'
class CharacterRepository extends BaseRepository {
async getByUserId(userId: number): Promise<Character[]> {
async getByUserId(userId: UUID, populate?: LoadHint<Character, '*'>): Promise<Character[]> {
try {
const repository = this.em.getRepository(Character)
return await repository.find({ user: userId })
return await repository.find({ user: userId }, { populate: populate as Populate<Character> })
} catch (error: any) {
this.logger.error(`Failed to get character by user ID: ${error instanceof Error ? error.message : String(error)}`)
return []
}
}
async getByUserAndId(userId: number, characterId: number): Promise<Character | null> {
async getByUserAndId(userId: UUID, characterId: UUID): Promise<Character | null> {
try {
const repository = this.em.getRepository(Character)
return await repository.findOne({ user: userId, id: characterId })
@ -22,7 +24,7 @@ class CharacterRepository extends BaseRepository {
}
}
async getById(id: number, populate?: string[]): Promise<Character | null> {
async getById(id: UUID, populate?: string[]): Promise<Character | null> {
try {
const repository = this.em.getRepository(Character)
return await repository.findOne({ id })

View File

@ -1,4 +1,5 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { CharacterType } from '#entities/characterType'
class CharacterTypeRepository extends BaseRepository {
@ -18,11 +19,11 @@ class CharacterTypeRepository extends BaseRepository {
return await repository.findAll()
} catch (error: any) {
this.logger.error(`Failed to get all character types: ${error instanceof Error ? error.message : String(error)}`)
return null
return []
}
}
async getById(id: number) {
async getById(id: UUID) {
try {
const repository = this.em.getRepository(CharacterType)
return await repository.findOne({ id })

View File

@ -1,8 +1,9 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { Chat } from '#entities/chat'
class ChatRepository extends BaseRepository {
async getById(id: number): Promise<Chat[]> {
async getById(id: UUID): Promise<Chat[]> {
try {
const repository = this.em.getRepository(Chat)
return await repository.find({
@ -24,7 +25,7 @@ class ChatRepository extends BaseRepository {
}
}
async getByCharacterId(characterId: number): Promise<Chat[]> {
async getByCharacterId(characterId: UUID): Promise<Chat[]> {
try {
const repository = this.em.getRepository(Chat)
return await repository.find({ character: characterId })
@ -34,12 +35,12 @@ class ChatRepository extends BaseRepository {
}
}
async getByZoneId(zoneId: number): Promise<Chat[]> {
async getByMapId(mapId: UUID): Promise<Chat[]> {
try {
const repository = this.em.getRepository(Chat)
return await repository.find({ zone: zoneId })
return await repository.find({ map: mapId })
} catch (error: any) {
this.logger.error(`Failed to get chats by zone ID: ${error instanceof Error ? error.message : String(error)}`)
this.logger.error(`Failed to get chats by map ID: ${error instanceof Error ? error.message : String(error)}`)
return []
}
}

View File

@ -1,8 +1,9 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { Item } from '#entities/item'
class ItemRepository extends BaseRepository {
async getById(id: string): Promise<any> {
async getById(id: UUID): Promise<any> {
try {
const repository = this.em.getRepository(Item)
return await repository.findOne({ id })
@ -12,7 +13,7 @@ class ItemRepository extends BaseRepository {
}
}
async getByIds(ids: string[]): Promise<any> {
async getByIds(ids: UUID[]): Promise<any> {
try {
const repository = this.em.getRepository(Item)
return await repository.find({

View File

@ -0,0 +1,33 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { MapEventTile } from '#entities/mapEventTile'
class MapEventTileRepository extends BaseRepository {
async getAll(id: UUID): Promise<MapEventTile[]> {
try {
const repository = this.em.getRepository(MapEventTile)
return await repository.find({
map: id
})
} catch (error: any) {
this.logger.error(`Failed to get map event tiles: ${error.message}`)
return []
}
}
async getEventTileByMapIdAndPosition(mapId: UUID, positionX: number, positionY: number) {
try {
const repository = this.em.getRepository(MapEventTile)
return await repository.findOne({
map: mapId,
positionX: positionX,
positionY: positionY
})
} catch (error: any) {
this.logger.error(`Failed to get map event tile: ${error.message}`)
return null
}
}
}
export default new MapEventTileRepository()

View File

@ -0,0 +1,25 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { MapObject } from '#entities/mapObject'
class MapObjectRepository extends BaseRepository {
async getById(id: UUID): Promise<MapObject | null> {
try {
const repository = this.em.getRepository(MapObject)
return await repository.findOne({ id })
} catch (error: any) {
return null
}
}
async getAll(): Promise<MapObject[]> {
try {
const repository = this.em.getRepository(MapObject)
return await repository.findAll()
} catch (error: any) {
return []
}
}
}
export default new MapObjectRepository()

View File

@ -0,0 +1,73 @@
import { BaseRepository } from '#application/base/baseRepository'
import { UUID } from '#application/types'
import { Map } from '#entities/map'
import { MapEventTile } from '#entities/mapEventTile'
import { MapObject } from '#entities/mapObject'
class MapRepository extends BaseRepository {
async getFirst(): Promise<Map | null> {
try {
const repository = this.em.getRepository(Map)
return await repository.findOne({ id: { $exists: true } })
} catch (error: any) {
this.logger.error(`Failed to get first map: ${error instanceof Error ? error.message : String(error)}`)
return null
}
}
async getAll(): Promise<Map[]> {
try {
const repository = this.em.getRepository(Map)
return await repository.findAll()
} catch (error: any) {
this.logger.error(`Failed to get all map: ${error.message}`)
return []
}
}
async getById(id: UUID) {
try {
const repository = this.em.getRepository(Map)
return await repository.findOne({ id })
} catch (error: any) {
this.logger.error(`Failed to get map by id: ${error.message}`)
return null
}
}
async getEventTiles(id: UUID): Promise<MapEventTile[]> {
try {
const repository = this.em.getRepository(MapEventTile)
return await repository.find({ map: id })
} catch (error: any) {
this.logger.error(`Failed to get map event tiles: ${error.message}`)
return []
}
}
async getFirstEventTile(mapId: UUID, positionX: number, positionY: number): Promise<MapEventTile | null> {
try {
const repository = this.em.getRepository(MapEventTile)
return await repository.findOne({
map: mapId,
positionX: positionX,
positionY: positionY
})
} catch (error: any) {
this.logger.error(`Failed to get map event tile: ${error.message}`)
return null
}
}
async getMapObjects(id: UUID): Promise<MapObject[]> {
try {
const repository = this.em.getRepository(MapObject)
return await repository.find({ map: id })
} catch (error: any) {
this.logger.error(`Failed to get map objects: ${error.message}`)
return []
}
}
}
export default new MapRepository()

Some files were not shown because too many files have changed in this diff Show More