From 211779785da759ce8fcc4e1f1059b9772fcd5693 Mon Sep 17 00:00:00 2001 From: gpatruno Date: Sat, 6 Jun 2026 23:28:59 +0200 Subject: [PATCH] selector --- .../com/disklexar/mmorpg/MmorpgPlugin.java | 6 +- .../disklexar/mmorpg/bootstrap/Bootstrap.java | 32 +- .../mmorpg/combat/PassiveEffectService.java | 53 +- .../mmorpg/command/CharacterCommand.java | 227 +++++++ .../mmorpg/command/MmorpgCommand.java | 1 + .../mmorpg/command/MmorpgHelpCommand.java | 1 + .../mmorpg/command/PasswordCommand.java | 4 +- .../mmorpg/core/config/MmorpgConfig.java | 6 + .../events/PlayerConnectionHandler.java | 86 ++- .../mmorpg/persistence/PasswordHasher.java | 2 +- .../CharacterInventoryRepository.java | 117 ++++ .../repository/CharacterRepository.java | 169 +++++ .../repository/PlayerAccountRepository.java | 166 +++++ .../repository/PlayerProfileRepository.java | 193 ------ .../persistence/schema/SchemaInitializer.java | 14 +- .../player/CharacterInventoryService.java | 128 ++++ .../mmorpg/player/CharacterService.java | 272 ++++++++ .../mmorpg/player/PlayerInfoView.java | 2 +- .../mmorpg/player/PlayerSessionService.java | 189 ++++-- .../mmorpg/player/model/PlayerAccount.java | 95 +++ .../mmorpg/player/model/PlayerProfile.java | 39 +- .../mmorpg/ui/CharacterSelectPage.java | 247 +++++++ .../UI/Custom/Pages/MmorpgCharacterSelect.ui | 153 +++++ .../mmorpg/src/main/resources/config.json | 1 + .../db/migrations/004_characters.sql | 67 ++ Webapp/README.md | 2 +- Webapp/package-lock.json | 610 +++++++++++++++++- Webapp/src/auth.js | 2 +- Webapp/src/db.js | 118 +++- 29 files changed, 2679 insertions(+), 323 deletions(-) create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterInventoryRepository.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerAccountRepository.java delete mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerAccount.java create mode 100644 Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java create mode 100644 Hytale/plugins/mmorpg/src/main/resources/Common/UI/Custom/Pages/MmorpgCharacterSelect.ui create mode 100644 Hytale/plugins/mmorpg/src/main/resources/db/migrations/004_characters.sql diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java index 1f528fc..8dd89d7 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/MmorpgPlugin.java @@ -118,7 +118,11 @@ public final class MmorpgPlugin extends JavaPlugin { OpenCustomUIInteraction.registerSimple(this, PlayerInfoPage.class, "mmorpg_player_info", playerRef -> { PlayerProfile profile = sessions.getSession(playerRef.getUuid()); if (profile == null) { - profile = new PlayerProfile(playerRef.getUuid(), playerRef.getUsername(), System.currentTimeMillis()); + profile = new PlayerProfile( + java.util.UUID.randomUUID(), + playerRef.getUuid(), + playerRef.getUsername(), + System.currentTimeMillis()); } return new PlayerInfoPage(playerRef, profile); }); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java index 693b54e..cc9eea4 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/bootstrap/Bootstrap.java @@ -11,8 +11,12 @@ import com.disklexar.mmorpg.combat.StunService; import com.disklexar.mmorpg.core.config.MmorpgConfig; import com.disklexar.mmorpg.economy.EconomyService; import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.persistence.repository.CharacterInventoryRepository; +import com.disklexar.mmorpg.persistence.repository.CharacterRepository; import com.disklexar.mmorpg.persistence.repository.GroupRepository; -import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerAccountRepository; +import com.disklexar.mmorpg.player.CharacterInventoryService; +import com.disklexar.mmorpg.player.CharacterService; import com.disklexar.mmorpg.player.OnlinePlayerRegistry; import com.disklexar.mmorpg.player.PlayerSessionService; import com.disklexar.mmorpg.progression.ProgressionService; @@ -46,12 +50,12 @@ public final class Bootstrap { public void initialize() throws IOException { config = MmorpgConfig.load(plugin.getDataDirectory()); - // Persistence (DatabaseManager must start first so repositories can query). DatabaseManager databaseManager = new DatabaseManager(plugin, config); - PlayerProfileRepository profileRepository = new PlayerProfileRepository(databaseManager); + PlayerAccountRepository accountRepository = new PlayerAccountRepository(databaseManager); + CharacterRepository characterRepository = new CharacterRepository(databaseManager); + CharacterInventoryRepository inventoryRepository = new CharacterInventoryRepository(databaseManager); GroupRepository groupRepository = new GroupRepository(databaseManager); - // Stateless domain services. ProgressionService progression = new ProgressionService(config.getBaseXpPerLevel()); EconomyService economy = new EconomyService(); ClassService classService = new ClassService(); @@ -63,9 +67,17 @@ public final class Bootstrap { StunService stunService = new StunService(); OnlinePlayerRegistry onlinePlayers = new OnlinePlayerRegistry(); - // Session and lifecycle services. - PlayerSessionService sessionService = - new PlayerSessionService(plugin, config, profileRepository); + PlayerSessionService sessionService = new PlayerSessionService( + plugin, config, accountRepository, characterRepository); + CharacterInventoryService characterInventoryService = + new CharacterInventoryService(inventoryRepository); + CharacterService characterService = new CharacterService( + plugin, + config, + characterRepository, + accountRepository, + characterInventoryService, + sessionService); MmorpgScheduler scheduler = new MmorpgScheduler(plugin); PoisonService poisonService = new PoisonService(scheduler); GroupService groupService = @@ -79,7 +91,9 @@ public final class Bootstrap { registry.register(MmorpgConfig.class, config); registry.register(DatabaseManager.class, databaseManager); - registry.register(PlayerProfileRepository.class, profileRepository); + registry.register(PlayerAccountRepository.class, accountRepository); + registry.register(CharacterRepository.class, characterRepository); + registry.register(CharacterInventoryRepository.class, inventoryRepository); registry.register(GroupRepository.class, groupRepository); registry.register(ProgressionService.class, progression); registry.register(EconomyService.class, economy); @@ -93,6 +107,8 @@ public final class Bootstrap { registry.register(StunService.class, stunService); registry.register(OnlinePlayerRegistry.class, onlinePlayers); registry.register(PlayerSessionService.class, sessionService); + registry.register(CharacterInventoryService.class, characterInventoryService); + registry.register(CharacterService.class, characterService); registry.register(MmorpgScheduler.class, scheduler); registry.register(GroupService.class, groupService); registry.register(AbilityService.class, abilityService); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java index 0b05871..d0e8008 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/combat/PassiveEffectService.java @@ -11,9 +11,13 @@ import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.protocol.MovementSettings; import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; import java.util.concurrent.ScheduledFuture; /** @@ -71,22 +75,43 @@ public final class PassiveEffectService implements Service { } private void applySpeed(@Nonnull OnlinePlayer player, boolean buffed) { - Store store = player.world().getEntityStore().getStore(); - Ref ref = player.entityRef(); - MovementManager movement = store.getComponent(ref, MovementManager.getComponentType()); - if (movement == null) { + Ref ref = resolveEntityRef(player.world(), player.uuid()); + if (ref == null) { return; } - MovementSettings defaults = movement.getDefaultSettings(); - MovementSettings current = movement.getSettings(); - if (defaults == null || current == null) { - return; - } - float baseline = defaults.baseSpeed; - float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline; - if (Math.abs(current.baseSpeed - target) > 0.0001f) { - current.baseSpeed = target; - movement.update(player.playerRef().getPacketHandler()); + try { + Store store = player.world().getEntityStore().getStore(); + MovementManager movement = store.getComponent(ref, MovementManager.getComponentType()); + if (movement == null) { + return; + } + MovementSettings defaults = movement.getDefaultSettings(); + MovementSettings current = movement.getSettings(); + if (defaults == null || current == null) { + return; + } + float baseline = defaults.baseSpeed; + float target = buffed ? baseline * (float) (1.0 + PowerCatalog.SPRINTER_SPEED_BONUS) : baseline; + if (Math.abs(current.baseSpeed - target) > 0.0001f) { + current.baseSpeed = target; + movement.update(player.playerRef().getPacketHandler()); + } + } catch (IllegalStateException ignored) { + // Player entity was removed while the reconcile task was queued. } } + + @Nullable + private static Ref resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) { + for (PlayerRef playerRef : world.getPlayerRefs()) { + if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) { + continue; + } + Ref entityRef = playerRef.getReference(); + if (entityRef != null && entityRef.isValid()) { + return entityRef; + } + } + return null; + } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java new file mode 100644 index 0000000..3862d8c --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/CharacterCommand.java @@ -0,0 +1,227 @@ +package com.disklexar.mmorpg.command; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.player.CharacterService; +import com.disklexar.mmorpg.player.PlayerSessionService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractPlayerCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import java.util.logging.Level; + +/** + * {@code /mmorpg character } + */ +public final class CharacterCommand extends AbstractCommandCollection { + + public CharacterCommand(@Nonnull MmorpgPlugin plugin) { + super("character", "Gérer vos personnages"); + addSubCommand(new ListCmd(plugin)); + addSubCommand(new Create(plugin)); + addSubCommand(new Choose(plugin)); + addSubCommand(new Selector(plugin)); + } + + private static final class ListCmd extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + ListCmd(@Nonnull MmorpgPlugin plugin) { + super("liste", "Lister vos personnages"); + this.plugin = plugin; + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + UUID accountUuid = playerRef.getUuid(); + CharacterService characters = CommandSupport.service(plugin, CharacterService.class); + if (characters == null) { + context.sendMessage(Message.raw("Service personnages indisponible.")); + return; + } + try { + List profiles = characters.listCharacters(accountUuid); + if (profiles.isEmpty()) { + context.sendMessage(Message.raw("Aucun personnage. Utilisez /mmorpg character creer .")); + return; + } + context.sendMessage(Message.raw("=== Vos personnages ===")); + PlayerProfile active = CommandSupport.profile(plugin, store, ref); + for (PlayerProfile profile : profiles) { + PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); + String className = playerClass == null ? "Sans classe" : playerClass.displayName(); + String marker = active != null + && active.getCharacterId().equals(profile.getCharacterId()) ? " [actif]" : ""; + context.sendMessage(Message.raw(String.format( + "• %s — Nv.%d — %s%s", + profile.getDisplayName(), + profile.getLevel(), + className, + marker))); + } + } catch (SQLException e) { + context.sendMessage(Message.raw("Erreur lors du chargement des personnages.")); + } + } + } + + private static final class Create extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final OptionalArg nameArg; + + Create(@Nonnull MmorpgPlugin plugin) { + super("creer", "Créer un personnage"); + this.plugin = plugin; + this.nameArg = withOptionalArg("nom", "Nom du personnage", ArgTypes.STRING); + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + CharacterService characters = CommandSupport.service(plugin, CharacterService.class); + if (characters == null) { + context.sendMessage(Message.raw("Service personnages indisponible.")); + return; + } + try { + String name = context.get(nameArg); + if (name == null || name.isBlank()) { + name = characters.nextDefaultName(playerRef.getUuid()); + } + CharacterService.CharacterCreationResult result = + characters.createCharacter(playerRef.getUuid(), name); + if (result.ok() && result.profile() != null) { + context.sendMessage(Message.raw( + "Personnage créé : " + result.profile().getDisplayName())); + } else { + context.sendMessage(Message.raw( + result.errorMessage() != null ? result.errorMessage() : "Création impossible.")); + } + } catch (SQLException e) { + plugin.getLogger().at(Level.WARNING).log("Character create failed: %s", e.getMessage()); + context.sendMessage(Message.raw("Erreur lors de la création du personnage.")); + } + } + } + + private static final class Choose extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + private final RequiredArg targetArg; + + Choose(@Nonnull MmorpgPlugin plugin) { + super("choisir", "Changer de personnage actif"); + this.plugin = plugin; + this.targetArg = withRequiredArg("personnage", "Nom ou UUID du personnage", ArgTypes.STRING); + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + CharacterService characters = CommandSupport.service(plugin, CharacterService.class); + PlayerSessionService sessions = CommandSupport.service(plugin, PlayerSessionService.class); + AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class); + if (characters == null || sessions == null) { + context.sendMessage(Message.raw("Service personnages indisponible.")); + return; + } + + UUID accountUuid = playerRef.getUuid(); + try { + var found = characters.findCharacter(accountUuid, context.get(targetArg)); + if (found.isEmpty()) { + context.sendMessage(Message.raw("Personnage introuvable.")); + return; + } + + PlayerProfile current = sessions.getSession(accountUuid); + if (current != null && current.getCharacterId().equals(found.get().getCharacterId())) { + context.sendMessage(Message.raw("Ce personnage est déjà actif.")); + return; + } + + if (abilityBar != null) { + abilityBar.dismiss(accountUuid); + } + + PlayerSessionService.AccountSession session = sessions.requireSession(accountUuid); + characters.persistActiveCharacter(session, store, ref); + + world.execute(() -> { + try { + characters.activateCharacter( + playerRef, ref, store, accountUuid, found.get().getCharacterId()); + context.sendMessage(Message.raw( + "Personnage actif : " + found.get().getDisplayName())); + } catch (SQLException e) { + plugin.getLogger().at(Level.WARNING).log("Character switch failed: %s", e.getMessage()); + context.sendMessage(Message.raw("Impossible de changer de personnage.")); + } + }); + } catch (SQLException e) { + context.sendMessage(Message.raw("Erreur lors du changement de personnage.")); + } + } + } + + private static final class Selector extends AbstractPlayerCommand { + private final MmorpgPlugin plugin; + + Selector(@Nonnull MmorpgPlugin plugin) { + super("selecteur", "Ouvrir le sélecteur de personnages"); + this.plugin = plugin; + } + + @Override + protected void execute( + @Nonnull CommandContext context, + @Nonnull Store store, + @Nonnull Ref ref, + @Nonnull PlayerRef playerRef, + @Nonnull World world) { + CharacterService characters = CommandSupport.service(plugin, CharacterService.class); + if (characters == null) { + context.sendMessage(Message.raw("Service personnages indisponible.")); + return; + } + + world.execute(() -> { + try { + characters.openCharacterSelector(playerRef, ref, store, playerRef.getUuid()); + } catch (SQLException e) { + plugin.getLogger().at(Level.WARNING).log( + "Failed to open character selector: %s", e.getMessage()); + context.sendMessage(Message.raw("Impossible d'ouvrir le selecteur de personnages.")); + } + }); + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java index 87c14ac..a425ff6 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgCommand.java @@ -22,6 +22,7 @@ public final class MmorpgCommand extends AbstractCommandCollection { addSubCommand(new PowerCommand(plugin)); addSubCommand(new JobCommand(plugin)); addSubCommand(new GroupCommand(plugin)); + addSubCommand(new CharacterCommand(plugin)); addSubCommand(new AbilityCommand(plugin)); addSubCommand(new PasswordCommand(plugin)); } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java index c674ae1..9b9c640 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/MmorpgHelpCommand.java @@ -32,6 +32,7 @@ public final class MmorpgHelpCommand extends AbstractAsyncCommand { /mmorpg power [pouvoir] /mmorpg job [metier] /mmorpg group [joueur] + /mmorpg character [nom] /mmorpg ability <1|2|3> — utiliser une capacité de classe /mmorpg password — mot de passe pour le site web Orion.ovh (ou utilisez directement les touches « Use ability 1/2/3 » en jeu) diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/PasswordCommand.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/PasswordCommand.java index 804a93b..1b4677e 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/PasswordCommand.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/command/PasswordCommand.java @@ -2,7 +2,7 @@ package com.disklexar.mmorpg.command; import com.disklexar.mmorpg.MmorpgPlugin; import com.disklexar.mmorpg.persistence.PasswordHasher; -import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerAccountRepository; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.server.core.Message; @@ -44,7 +44,7 @@ public final class PasswordCommand extends AbstractPlayerCommand { @Nonnull PlayerRef playerRef, @Nonnull World world) { UUID uuid = CommandSupport.uuid(store, ref); - PlayerProfileRepository profiles = CommandSupport.service(plugin, PlayerProfileRepository.class); + PlayerAccountRepository profiles = CommandSupport.service(plugin, PlayerAccountRepository.class); if (uuid == null || profiles == null) { context.sendMessage(Message.raw("Profil indisponible.")); return; diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java index 422ff8d..e85818c 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/core/config/MmorpgConfig.java @@ -26,6 +26,8 @@ public final class MmorpgConfig { private int baseXpPerLevel = 100; @SerializedName("KillExperience") private int killExperience = 10; + @SerializedName("MaxCharactersPerAccount") + private int maxCharactersPerAccount = 5; private DatabaseConfig database = new DatabaseConfig(); private FeaturesConfig features = new FeaturesConfig(); @@ -75,6 +77,10 @@ public final class MmorpgConfig { return killExperience; } + public int getMaxCharactersPerAccount() { + return maxCharactersPerAccount; + } + public DatabaseConfig getDatabase() { return database; } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java index bede16f..e2f8602 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/events/PlayerConnectionHandler.java @@ -2,21 +2,21 @@ package com.disklexar.mmorpg.events; import com.disklexar.mmorpg.MmorpgPlugin; import com.disklexar.mmorpg.bootstrap.Bootstrap; -import com.disklexar.mmorpg.player.OnlinePlayer; import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.player.CharacterService; +import com.disklexar.mmorpg.player.OnlinePlayer; import com.disklexar.mmorpg.player.OnlinePlayerRegistry; import com.disklexar.mmorpg.player.PlayerSessionService; -import com.disklexar.mmorpg.player.model.PlayerProfile; -import com.disklexar.mmorpg.rpg.clazz.ClassService; import com.disklexar.mmorpg.ui.AbilityBarService; +import com.disklexar.mmorpg.ui.CharacterSelectPage; import com.hypixel.hytale.component.Ref; import com.hypixel.hytale.component.Store; import com.hypixel.hytale.logger.HytaleLogger; -import com.hypixel.hytale.server.core.Message; import com.hypixel.hytale.server.core.entity.UUIDComponent; import com.hypixel.hytale.server.core.entity.entities.Player; import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent; import com.hypixel.hytale.server.core.event.events.player.PlayerReadyEvent; +import com.hypixel.hytale.server.core.inventory.InventoryUtils; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; @@ -50,41 +50,28 @@ public final class PlayerConnectionHandler { Store store = player.getWorld().getEntityStore().getStore(); PlayerRef universePlayerRef = store.getComponent(entityRef, PlayerRef.getComponentType()); PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); + CharacterService characters = bootstrap.getRegistry().require(CharacterService.class); + MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class); try { UUID uuid = resolveUuid(store, entityRef, player); String displayName = resolveDisplayName(store, entityRef, player); - PlayerProfile profile = sessions.loadOrCreate(uuid, displayName); + sessions.beginSession(uuid, displayName); + + InventoryUtils.clear(entityRef, store); if (universePlayerRef != null) { - OnlinePlayerRegistry onlinePlayers = - bootstrap.getRegistry().require(OnlinePlayerRegistry.class); - OnlinePlayer online = new OnlinePlayer(uuid, universePlayerRef, entityRef, player.getWorld()); - onlinePlayers.add(online); - - ClassService classService = bootstrap.getRegistry().require(ClassService.class); - AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class); - MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class); - - universePlayerRef.sendMessage(Message.raw(String.format( - "Bienvenue sur le serveur MMORPG — Niveau %d | Race: %s", - profile.getLevel(), - com.disklexar.mmorpg.rpg.race.RaceCatalog.byId(profile.getRaceId()).displayName()))); - universePlayerRef.sendMessage(Message.raw( - "Commandes : /mmorpg menu (profil) | /mmorpg inventory | /mmorpg class info")); - - if (classService.hasClass(profile)) { - scheduler.runLater(() -> abilityBar.sync(uuid, profile), 5_000L); - } + scheduler.runLater(() -> openCharacterSelector( + player, entityRef, store, universePlayerRef, characters, uuid), 500L); } } catch (SQLException e) { logger.at(Level.SEVERE).log( - "Failed to load profile for %s: %s", + "Failed to load account for %s: %s", resolveDisplayName(store, entityRef, player), e.getMessage()); if (universePlayerRef != null) { - universePlayerRef.sendMessage(Message.raw( - "Erreur lors du chargement de votre profil MMORPG.")); + universePlayerRef.sendMessage(com.hypixel.hytale.server.core.Message.raw( + "Erreur lors du chargement de votre compte MMORPG.")); } } } @@ -96,11 +83,50 @@ public final class PlayerConnectionHandler { } PlayerRef playerRef = event.getPlayerRef(); + UUID accountUuid = playerRef.getUuid(); OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class); - onlinePlayers.remove(playerRef.getUuid()); - bootstrap.getRegistry().require(AbilityBarService.class).dismiss(playerRef.getUuid()); + AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class); + CharacterService characters = bootstrap.getRegistry().require(CharacterService.class); PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class); - sessions.unload(playerRef.getUuid()); + + OnlinePlayer online = onlinePlayers.get(accountUuid); + PlayerSessionService.AccountSession session = sessions.getAccountSession(accountUuid); + Ref entityRef = playerRef.getReference(); + if (session != null + && session.activeCharacter() != null + && online != null + && entityRef != null + && entityRef.isValid()) { + Store store = online.world().getEntityStore().getStore(); + characters.persistActiveCharacter(session, store, entityRef); + } + + onlinePlayers.remove(accountUuid); + abilityBar.dismiss(accountUuid); + sessions.unload(accountUuid); + } + + private void openCharacterSelector( + @Nonnull Player player, + @Nonnull Ref entityRef, + @Nonnull Store store, + @Nonnull PlayerRef playerRef, + @Nonnull CharacterService characters, + @Nonnull UUID accountUuid) { + player.getWorld().execute(() -> { + try { + player.getPageManager().openCustomPage(entityRef, store, new CharacterSelectPage( + playerRef, + accountUuid, + characters, + plugin.getBootstrap().getConfig().getMaxCharactersPerAccount())); + } catch (Throwable t) { + logger.at(Level.WARNING).log( + "Failed to open character selector for %s: %s", + playerRef.getUsername(), + t.getMessage()); + } + }); } @Nonnull diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/PasswordHasher.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/PasswordHasher.java index c3bd522..fd98151 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/PasswordHasher.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/PasswordHasher.java @@ -10,7 +10,7 @@ import javax.annotation.Nonnull; public final class PasswordHasher { private static final int COST = 10; - public static final int MIN_LENGTH = 6; + public static final int MIN_LENGTH = 3; public static final int MAX_LENGTH = 72; private PasswordHasher() { diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterInventoryRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterInventoryRepository.java new file mode 100644 index 0000000..1e9863d --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterInventoryRepository.java @@ -0,0 +1,117 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * SQLite persistence for per-character inventory slots. + */ +public final class CharacterInventoryRepository { + + private final DatabaseManager databaseManager; + + public CharacterInventoryRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + public record InventorySlotRecord( + int sectionId, + int slotIndex, + @Nonnull String itemId, + int quantity, + @Nullable Double durability, + @Nullable Double maxDurability, + @Nullable String metadataJson) { + } + + @Nonnull + public List load(@Nonnull UUID characterId) throws SQLException { + String sql = """ + SELECT section_id, slot_index, item_id, quantity, durability, max_durability, metadata + FROM character_inventory + WHERE character_id = ? + ORDER BY section_id, slot_index + """; + List slots = new ArrayList<>(); + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, characterId.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + slots.add(new InventorySlotRecord( + resultSet.getInt("section_id"), + resultSet.getInt("slot_index"), + resultSet.getString("item_id"), + resultSet.getInt("quantity"), + readNullableDouble(resultSet, "durability"), + readNullableDouble(resultSet, "max_durability"), + resultSet.getString("metadata"))); + } + } + } + return slots; + } + + public void replaceAll(@Nonnull UUID characterId, @Nonnull List slots) + throws SQLException { + Connection connection = connection(); + try (PreparedStatement delete = connection.prepareStatement( + "DELETE FROM character_inventory WHERE character_id = ?")) { + delete.setString(1, characterId.toString()); + delete.executeUpdate(); + } + + if (slots.isEmpty()) { + return; + } + + String sql = """ + INSERT INTO character_inventory ( + character_id, section_id, slot_index, item_id, quantity, + durability, max_durability, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + try (PreparedStatement insert = connection.prepareStatement(sql)) { + for (InventorySlotRecord slot : slots) { + insert.setString(1, characterId.toString()); + insert.setInt(2, slot.sectionId()); + insert.setInt(3, slot.slotIndex()); + insert.setString(4, slot.itemId()); + insert.setInt(5, slot.quantity()); + if (slot.durability() != null) { + insert.setDouble(6, slot.durability()); + } else { + insert.setNull(6, java.sql.Types.REAL); + } + if (slot.maxDurability() != null) { + insert.setDouble(7, slot.maxDurability()); + } else { + insert.setNull(7, java.sql.Types.REAL); + } + insert.setString(8, slot.metadataJson()); + insert.addBatch(); + } + insert.executeBatch(); + } + } + + @Nullable + private static Double readNullableDouble(@Nonnull ResultSet resultSet, @Nonnull String column) + throws SQLException { + double value = resultSet.getDouble(column); + return resultSet.wasNull() ? null : value; + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java new file mode 100644 index 0000000..db7e6e3 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/CharacterRepository.java @@ -0,0 +1,169 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.lang.reflect.Type; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * SQLite persistence for playable characters. + */ +public final class CharacterRepository { + + private static final Gson GSON = new Gson(); + private static final Type STRING_LIST = new TypeToken>() { + }.getType(); + + private final DatabaseManager databaseManager; + + public CharacterRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public Optional findById(@Nonnull UUID characterId) throws SQLException { + return findByQuery("WHERE id = ?", characterId.toString()).stream().findFirst(); + } + + @Nonnull + public List findByAccountUuid(@Nonnull UUID accountUuid) throws SQLException { + return findByQuery("WHERE account_uuid = ? ORDER BY date_creation ASC", accountUuid.toString()); + } + + public int countByAccount(@Nonnull UUID accountUuid) throws SQLException { + String sql = "SELECT COUNT(*) FROM characters WHERE account_uuid = ?"; + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, accountUuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getInt(1) : 0; + } + } + } + + public boolean nameExists(@Nonnull UUID accountUuid, @Nonnull String name) throws SQLException { + String sql = """ + SELECT 1 FROM characters + WHERE account_uuid = ? AND name = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, accountUuid.toString()); + statement.setString(2, name.trim()); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } + } + } + + @Nonnull + public PlayerProfile save(@Nonnull PlayerProfile profile) throws SQLException { + String sql = """ + INSERT INTO characters ( + id, account_uuid, name, level, experience, class_id, powers, jobs, + guild_id, group_id, money, race_id, date_creation, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + level = excluded.level, + experience = excluded.experience, + class_id = excluded.class_id, + powers = excluded.powers, + jobs = excluded.jobs, + guild_id = excluded.guild_id, + group_id = excluded.group_id, + money = excluded.money, + race_id = excluded.race_id, + updated_at = excluded.updated_at + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, profile.getCharacterId().toString()); + statement.setString(2, profile.getAccountUuid().toString()); + statement.setString(3, profile.getDisplayName()); + statement.setInt(4, profile.getLevel()); + statement.setLong(5, profile.getExperience()); + statement.setString(6, profile.getClassId()); + statement.setString(7, GSON.toJson(profile.getPowers())); + statement.setString(8, GSON.toJson(profile.getJobs())); + statement.setString(9, profile.getGuildId()); + statement.setString(10, profile.getGroupId()); + statement.setLong(11, profile.getMoney()); + statement.setString(12, profile.getRaceId()); + statement.setLong(13, profile.getDateCreation()); + statement.setLong(14, profile.getUpdatedAt()); + statement.executeUpdate(); + } + + return profile; + } + + @Nonnull + private List findByQuery(@Nonnull String whereClause, @Nonnull String... params) + throws SQLException { + String sql = """ + SELECT id, account_uuid, name, level, experience, class_id, powers, jobs, + guild_id, group_id, money, race_id, date_creation, updated_at + FROM characters + """ + whereClause; + + List profiles = new ArrayList<>(); + try (PreparedStatement statement = connection().prepareStatement(sql)) { + for (int i = 0; i < params.length; i++) { + statement.setString(i + 1, params[i]); + } + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + profiles.add(mapRow(resultSet)); + } + } + } + return profiles; + } + + @Nonnull + private PlayerProfile mapRow(@Nonnull ResultSet resultSet) throws SQLException { + PlayerProfile profile = new PlayerProfile( + UUID.fromString(resultSet.getString("id")), + UUID.fromString(resultSet.getString("account_uuid")), + resultSet.getString("name"), + resultSet.getLong("date_creation")); + profile.setLevel(resultSet.getInt("level")); + profile.setExperience(resultSet.getLong("experience")); + profile.setClassId(resultSet.getString("class_id")); + profile.getPowers().addAll(parseList(resultSet.getString("powers"))); + profile.getJobs().addAll(parseList(resultSet.getString("jobs"))); + profile.setGuildId(resultSet.getString("guild_id")); + profile.setGroupId(resultSet.getString("group_id")); + profile.setMoney(resultSet.getLong("money")); + String raceId = resultSet.getString("race_id"); + profile.setRaceId(raceId != null ? raceId : "human"); + profile.setUpdatedAt(resultSet.getLong("updated_at")); + return profile; + } + + @Nonnull + private List parseList(@Nullable String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + List parsed = GSON.fromJson(json, STRING_LIST); + return parsed != null ? parsed : List.of(); + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerAccountRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerAccountRepository.java new file mode 100644 index 0000000..69bf9d6 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerAccountRepository.java @@ -0,0 +1,166 @@ +package com.disklexar.mmorpg.persistence.repository; + +import com.disklexar.mmorpg.persistence.DatabaseManager; +import com.disklexar.mmorpg.player.model.PlayerAccount; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; +import java.util.UUID; + +/** + * SQLite persistence for {@link PlayerAccount} rows ({@code player_profiles} table). + */ +public final class PlayerAccountRepository { + + private final DatabaseManager databaseManager; + + public PlayerAccountRepository(@Nonnull DatabaseManager databaseManager) { + this.databaseManager = databaseManager; + } + + @Nonnull + public Optional findByUuid(@Nonnull UUID uuid) throws SQLException { + String sql = """ + SELECT uuid, display_name, active_character_id, is_connected, total_time_play, + last_date_connected, date_creation, updated_at + FROM player_profiles + WHERE uuid = ? + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, uuid.toString()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(mapRow(resultSet)); + } + } + } + + @Nonnull + public PlayerAccount save(@Nonnull PlayerAccount account) throws SQLException { + String sql = """ + INSERT INTO player_profiles ( + uuid, display_name, active_character_id, is_connected, total_time_play, + last_date_connected, date_creation, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET + display_name = excluded.display_name, + active_character_id = excluded.active_character_id, + is_connected = excluded.is_connected, + total_time_play = excluded.total_time_play, + last_date_connected = excluded.last_date_connected, + updated_at = excluded.updated_at + """; + + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, account.getUuid().toString()); + statement.setString(2, account.getDisplayName()); + statement.setString(3, account.getActiveCharacterId() == null + ? null + : account.getActiveCharacterId().toString()); + statement.setInt(4, account.isConnected() ? 1 : 0); + statement.setLong(5, account.getTotalTimePlay()); + statement.setLong(6, account.getLastDateConnected()); + statement.setLong(7, account.getDateCreation()); + statement.setLong(8, account.getDateCreation()); + statement.setLong(9, account.getUpdatedAt()); + statement.executeUpdate(); + } + + return account; + } + + public void setActiveCharacter(@Nonnull UUID accountUuid, @Nullable UUID characterId) throws SQLException { + String sql = """ + UPDATE player_profiles + SET active_character_id = ?, updated_at = ? + WHERE uuid = ? + """; + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, characterId == null ? null : characterId.toString()); + statement.setLong(2, System.currentTimeMillis()); + statement.setString(3, accountUuid.toString()); + statement.executeUpdate(); + } + } + + public void setPasswordHash(@Nonnull UUID uuid, @Nonnull String passwordHash) throws SQLException { + String sql = """ + UPDATE player_profiles + SET password_hash = ?, updated_at = ? + WHERE uuid = ? + """; + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, passwordHash); + statement.setLong(2, System.currentTimeMillis()); + statement.setString(3, uuid.toString()); + if (statement.executeUpdate() == 0) { + throw new SQLException("Player account not found: " + uuid); + } + } + } + + @Nonnull + public Optional findAuthByDisplayName(@Nonnull String displayName) throws SQLException { + String sql = """ + SELECT uuid, display_name, password_hash + FROM player_profiles + WHERE display_name = ? COLLATE NOCASE + LIMIT 1 + """; + try (PreparedStatement statement = connection().prepareStatement(sql)) { + statement.setString(1, displayName.trim()); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return Optional.empty(); + } + return Optional.of(new PlayerAuthRecord( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("display_name"), + resultSet.getString("password_hash"))); + } + } + } + + public void clearConnectedFlags() throws SQLException { + try (PreparedStatement statement = connection().prepareStatement( + "UPDATE player_profiles SET is_connected = 0 WHERE is_connected <> 0")) { + statement.executeUpdate(); + } + } + + public record PlayerAuthRecord( + @Nonnull UUID uuid, + @Nonnull String displayName, + @Nullable String passwordHash) { + } + + @Nonnull + private PlayerAccount mapRow(@Nonnull ResultSet resultSet) throws SQLException { + PlayerAccount account = new PlayerAccount( + UUID.fromString(resultSet.getString("uuid")), + resultSet.getString("display_name"), + resultSet.getLong("date_creation")); + String activeId = resultSet.getString("active_character_id"); + if (activeId != null && !activeId.isBlank()) { + account.setActiveCharacterId(UUID.fromString(activeId)); + } + account.setConnected(resultSet.getInt("is_connected") != 0); + account.setTotalTimePlay(resultSet.getLong("total_time_play")); + account.setLastDateConnected(resultSet.getLong("last_date_connected")); + account.setUpdatedAt(resultSet.getLong("updated_at")); + return account; + } + + @Nonnull + private Connection connection() { + return databaseManager.getConnection(); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java deleted file mode 100644 index 9bc9c85..0000000 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/repository/PlayerProfileRepository.java +++ /dev/null @@ -1,193 +0,0 @@ -package com.disklexar.mmorpg.persistence.repository; - -import com.disklexar.mmorpg.persistence.DatabaseManager; -import com.disklexar.mmorpg.player.model.PlayerProfile; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; - -import javax.annotation.Nonnull; -import javax.annotation.Nullable; -import java.lang.reflect.Type; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -/** - * SQLite persistence for {@link PlayerProfile}. - */ -public final class PlayerProfileRepository { - - private static final Gson GSON = new Gson(); - private static final Type STRING_LIST = new TypeToken>() { - }.getType(); - - private final DatabaseManager databaseManager; - - public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) { - this.databaseManager = databaseManager; - } - - @Nonnull - public Optional findByUuid(@Nonnull UUID uuid) throws SQLException { - String sql = """ - SELECT uuid, display_name, level, experience, class_id, powers, jobs, - guild_id, group_id, is_connected, total_time_play, last_date_connected, - date_creation, money, race_id, updated_at - FROM player_profiles - WHERE uuid = ? - """; - - try (PreparedStatement statement = connection().prepareStatement(sql)) { - statement.setString(1, uuid.toString()); - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - return Optional.empty(); - } - return Optional.of(mapRow(resultSet)); - } - } - } - - @Nonnull - public PlayerProfile save(@Nonnull PlayerProfile profile) throws SQLException { - String sql = """ - INSERT INTO player_profiles ( - uuid, display_name, level, experience, class_id, powers, jobs, - guild_id, group_id, is_connected, total_time_play, last_date_connected, - date_creation, money, race_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uuid) DO UPDATE SET - display_name = excluded.display_name, - level = excluded.level, - experience = excluded.experience, - class_id = excluded.class_id, - powers = excluded.powers, - jobs = excluded.jobs, - guild_id = excluded.guild_id, - group_id = excluded.group_id, - is_connected = excluded.is_connected, - total_time_play = excluded.total_time_play, - last_date_connected = excluded.last_date_connected, - money = excluded.money, - race_id = excluded.race_id, - updated_at = excluded.updated_at - """; - - try (PreparedStatement statement = connection().prepareStatement(sql)) { - statement.setString(1, profile.getUuid().toString()); - statement.setString(2, profile.getDisplayName()); - statement.setInt(3, profile.getLevel()); - statement.setLong(4, profile.getExperience()); - statement.setString(5, profile.getClassId()); - statement.setString(6, GSON.toJson(profile.getPowers())); - statement.setString(7, GSON.toJson(profile.getJobs())); - statement.setString(8, profile.getGuildId()); - statement.setString(9, profile.getGroupId()); - statement.setInt(10, profile.isConnected() ? 1 : 0); - statement.setLong(11, profile.getTotalTimePlay()); - statement.setLong(12, profile.getLastDateConnected()); - statement.setLong(13, profile.getDateCreation()); - statement.setLong(14, profile.getMoney()); - statement.setString(15, profile.getRaceId()); - // created_at mirrors date_creation to keep the original 001 column populated. - statement.setLong(16, profile.getDateCreation()); - statement.setLong(17, profile.getUpdatedAt()); - statement.executeUpdate(); - } - - return profile; - } - - /** Resets {@code is_connected} for every profile (called on boot to recover from crashes). */ - public void clearConnectedFlags() throws SQLException { - try (PreparedStatement statement = connection().prepareStatement( - "UPDATE player_profiles SET is_connected = 0 WHERE is_connected <> 0")) { - statement.executeUpdate(); - } - } - - public void setPasswordHash(@Nonnull UUID uuid, @Nonnull String passwordHash) throws SQLException { - String sql = """ - UPDATE player_profiles - SET password_hash = ?, updated_at = ? - WHERE uuid = ? - """; - try (PreparedStatement statement = connection().prepareStatement(sql)) { - statement.setString(1, passwordHash); - statement.setLong(2, System.currentTimeMillis()); - statement.setString(3, uuid.toString()); - if (statement.executeUpdate() == 0) { - throw new SQLException("Player profile not found: " + uuid); - } - } - } - - @Nonnull - public Optional findAuthByDisplayName(@Nonnull String displayName) throws SQLException { - String sql = """ - SELECT uuid, display_name, password_hash - FROM player_profiles - WHERE display_name = ? COLLATE NOCASE - LIMIT 1 - """; - try (PreparedStatement statement = connection().prepareStatement(sql)) { - statement.setString(1, displayName.trim()); - try (ResultSet resultSet = statement.executeQuery()) { - if (!resultSet.next()) { - return Optional.empty(); - } - return Optional.of(new PlayerAuthRecord( - UUID.fromString(resultSet.getString("uuid")), - resultSet.getString("display_name"), - resultSet.getString("password_hash"))); - } - } - } - - public record PlayerAuthRecord( - @Nonnull UUID uuid, - @Nonnull String displayName, - @Nullable String passwordHash) { - } - - @Nonnull - private PlayerProfile mapRow(@Nonnull ResultSet resultSet) throws SQLException { - PlayerProfile profile = new PlayerProfile( - UUID.fromString(resultSet.getString("uuid")), - resultSet.getString("display_name"), - resultSet.getLong("date_creation")); - profile.setLevel(resultSet.getInt("level")); - profile.setExperience(resultSet.getLong("experience")); - profile.setClassId(resultSet.getString("class_id")); - profile.getPowers().addAll(parseList(resultSet.getString("powers"))); - profile.getJobs().addAll(parseList(resultSet.getString("jobs"))); - profile.setGuildId(resultSet.getString("guild_id")); - profile.setGroupId(resultSet.getString("group_id")); - profile.setConnected(resultSet.getInt("is_connected") != 0); - profile.setTotalTimePlay(resultSet.getLong("total_time_play")); - profile.setLastDateConnected(resultSet.getLong("last_date_connected")); - profile.setMoney(resultSet.getLong("money")); - String raceId = resultSet.getString("race_id"); - profile.setRaceId(raceId != null ? raceId : "human"); - profile.setUpdatedAt(resultSet.getLong("updated_at")); - return profile; - } - - @Nonnull - private List parseList(String json) { - if (json == null || json.isBlank()) { - return List.of(); - } - List parsed = GSON.fromJson(json, STRING_LIST); - return parsed != null ? parsed : List.of(); - } - - @Nonnull - private Connection connection() { - return databaseManager.getConnection(); - } -} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java index 56ef2ab..1a7ddc5 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/persistence/schema/SchemaInitializer.java @@ -26,7 +26,8 @@ public final class SchemaInitializer { private static final List MIGRATIONS = List.of( "001_init.sql", "002_rpg.sql", - "003_web_password.sql"); + "003_web_password.sql", + "004_characters.sql"); private SchemaInitializer() { } @@ -73,10 +74,10 @@ public final class SchemaInitializer { } private static void runMigration(@Nonnull Connection connection, @Nonnull String migration) throws SQLException { - String sql = loadMigration("/db/migrations/" + migration); + String sql = stripComments(loadMigration("/db/migrations/" + migration)); try (Statement statement = connection.createStatement()) { for (String chunk : sql.split(";")) { - String trimmed = stripComments(chunk).trim(); + String trimmed = chunk.trim(); if (!trimmed.isEmpty()) { statement.execute(trimmed); } @@ -88,8 +89,11 @@ public final class SchemaInitializer { private static String stripComments(@Nonnull String sql) { StringBuilder builder = new StringBuilder(); for (String line : sql.split("\n")) { - String trimmed = line.trim(); - if (trimmed.startsWith("--")) { + int commentStart = line.indexOf("--"); + if (commentStart >= 0) { + line = line.substring(0, commentStart); + } + if (line.trim().isEmpty()) { continue; } builder.append(line).append('\n'); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java new file mode 100644 index 0000000..ef09f76 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterInventoryService.java @@ -0,0 +1,128 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.persistence.repository.CharacterInventoryRepository; +import com.disklexar.mmorpg.persistence.repository.CharacterInventoryRepository.InventorySlotRecord; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.inventory.InventoryComponent; +import com.hypixel.hytale.server.core.inventory.InventoryUtils; +import com.hypixel.hytale.server.core.inventory.ItemStack; +import com.hypixel.hytale.server.core.inventory.container.ItemContainer; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Saves and restores player inventory per character in SQLite. + */ +public final class CharacterInventoryService { + + private static final int[] SECTION_IDS = { + InventoryComponent.STORAGE_SECTION_ID, + InventoryComponent.HOTBAR_SECTION_ID, + InventoryComponent.ARMOR_SECTION_ID, + InventoryComponent.UTILITY_SECTION_ID + }; + + private final CharacterInventoryRepository repository; + + public CharacterInventoryService(@Nonnull CharacterInventoryRepository repository) { + this.repository = repository; + } + + public void saveFromPlayer( + @Nonnull UUID characterId, + @Nonnull Store store, + @Nonnull Ref ref) throws SQLException { + repository.replaceAll(characterId, captureFromPlayer(store, ref)); + } + + public void loadToPlayer( + @Nonnull UUID characterId, + @Nonnull Store store, + @Nonnull Ref ref) throws SQLException { + applyToPlayer(repository.load(characterId), store, ref); + } + + @Nonnull + public List captureFromPlayer( + @Nonnull Store store, + @Nonnull Ref ref) { + List slots = new ArrayList<>(); + for (int sectionId : SECTION_IDS) { + ItemContainer container = sectionContainer(store, ref, sectionId); + if (container == null) { + continue; + } + for (short slot = 0; slot < container.getCapacity(); slot++) { + ItemStack stack = container.getItemStack(slot); + if (ItemStack.isEmpty(stack)) { + continue; + } + String itemId = stack.getItemId(); + if (itemId == null || itemId.isBlank()) { + continue; + } + Double durability = stack.getMaxDurability() > 0 ? stack.getDurability() : null; + Double maxDurability = stack.getMaxDurability() > 0 ? stack.getMaxDurability() : null; + slots.add(new InventorySlotRecord( + sectionId, + slot, + itemId, + stack.getQuantity(), + durability, + maxDurability, + null)); + } + } + return slots; + } + + public void applyToPlayer( + @Nonnull List slots, + @Nonnull Store store, + @Nonnull Ref ref) { + InventoryUtils.clear(ref, store); + for (InventorySlotRecord slot : slots) { + ItemContainer container = sectionContainer(store, ref, slot.sectionId()); + if (container == null || slot.slotIndex() < 0 || slot.slotIndex() >= container.getCapacity()) { + continue; + } + ItemStack stack = buildStack(slot); + if (ItemStack.isEmpty(stack)) { + continue; + } + container.setItemStackForSlot((short) slot.slotIndex(), stack); + } + } + + @Nonnull + private static ItemStack buildStack(@Nonnull InventorySlotRecord slot) { + ItemStack stack = new ItemStack(slot.itemId(), slot.quantity()); + if (slot.maxDurability() != null && slot.maxDurability() > 0) { + stack = stack.withMaxDurability(slot.maxDurability()); + if (slot.durability() != null) { + stack = stack.withDurability(slot.durability()); + } + } + return stack; + } + + @Nullable + private static ItemContainer sectionContainer( + @Nonnull Store store, + @Nonnull Ref ref, + int sectionId) { + var type = InventoryComponent.getComponentTypeById(sectionId); + if (type == null) { + return null; + } + InventoryComponent component = store.getComponent(ref, type); + return component == null ? null : component.getInventory(); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java new file mode 100644 index 0000000..2e3241f --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/CharacterService.java @@ -0,0 +1,272 @@ +package com.disklexar.mmorpg.player; + +import com.disklexar.mmorpg.MmorpgPlugin; +import com.disklexar.mmorpg.combat.MmorpgScheduler; +import com.disklexar.mmorpg.core.config.MmorpgConfig; +import com.disklexar.mmorpg.persistence.repository.CharacterRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerAccountRepository; +import com.disklexar.mmorpg.player.model.PlayerAccount; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassService; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.disklexar.mmorpg.ui.AbilityBarService; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.logger.HytaleLogger; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.protocol.packets.interface_.Page; +import com.hypixel.hytale.server.core.entity.entities.Player; +import com.hypixel.hytale.server.core.inventory.InventoryUtils; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Character creation, selection and switching. + */ +public final class CharacterService { + + private static final int MIN_NAME_LENGTH = 3; + private static final int MAX_NAME_LENGTH = 16; + + private final MmorpgPlugin plugin; + private final MmorpgConfig config; + private final CharacterRepository characters; + private final PlayerAccountRepository accounts; + private final CharacterInventoryService inventory; + private final PlayerSessionService sessions; + private final HytaleLogger logger; + + public CharacterService( + @Nonnull MmorpgPlugin plugin, + @Nonnull MmorpgConfig config, + @Nonnull CharacterRepository characters, + @Nonnull PlayerAccountRepository accounts, + @Nonnull CharacterInventoryService inventory, + @Nonnull PlayerSessionService sessions) { + this.plugin = plugin; + this.config = config; + this.characters = characters; + this.accounts = accounts; + this.inventory = inventory; + this.sessions = sessions; + this.logger = plugin.getLogger(); + } + + @Nonnull + public List listCharacters(@Nonnull UUID accountUuid) throws SQLException { + return characters.findByAccountUuid(accountUuid); + } + + @Nonnull + public Optional findCharacter( + @Nonnull UUID accountUuid, + @Nonnull String query) throws SQLException { + String trimmed = query.trim(); + for (PlayerProfile profile : characters.findByAccountUuid(accountUuid)) { + if (profile.getCharacterId().toString().equalsIgnoreCase(trimmed) + || profile.getDisplayName().equalsIgnoreCase(trimmed)) { + return Optional.of(profile); + } + } + return Optional.empty(); + } + + @Nonnull + public CharacterCreationResult createCharacter(@Nonnull UUID accountUuid, @Nonnull String rawName) + throws SQLException { + String name = normalizeName(rawName); + if (name == null) { + return CharacterCreationResult.invalidName(); + } + if (characters.countByAccount(accountUuid) >= config.getMaxCharactersPerAccount()) { + return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount()); + } + if (characters.nameExists(accountUuid, name)) { + return CharacterCreationResult.duplicateName(name); + } + + long now = System.currentTimeMillis(); + PlayerProfile profile = new PlayerProfile(UUID.randomUUID(), accountUuid, name, now); + profile.setLevel(config.getDefaultLevel()); + profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); + profile.touch(); + characters.save(profile); + logger.at(Level.INFO).log("Created character %s for account %s", name, accountUuid); + return CharacterCreationResult.success(profile); + } + + @Nonnull + public String nextDefaultName(@Nonnull UUID accountUuid) throws SQLException { + int count = characters.countByAccount(accountUuid); + return "Perso " + (count + 1); + } + + public void openCharacterSelector( + @Nonnull PlayerRef playerRef, + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull UUID accountUuid) throws SQLException { + PlayerSessionService.AccountSession session = sessions.getAccountSession(accountUuid); + if (session != null && session.activeCharacter() != null) { + persistActiveCharacter(session, store, ref); + } + sessions.beginCharacterSelection(accountUuid); + + var bootstrap = plugin.getBootstrap(); + if (bootstrap != null) { + bootstrap.getRegistry().require(AbilityBarService.class).dismiss(accountUuid); + } + + InventoryUtils.clear(ref, store); + + Player player = store.getComponent(ref, Player.getComponentType()); + if (player != null) { + player.getPageManager().openCustomPage(ref, store, new com.disklexar.mmorpg.ui.CharacterSelectPage( + playerRef, + accountUuid, + this, + config.getMaxCharactersPerAccount())); + } + } + + public void activateCharacter( + @Nonnull PlayerRef playerRef, + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull UUID accountUuid, + @Nonnull UUID characterId) throws SQLException { + Optional found = characters.findById(characterId); + if (found.isEmpty() || !found.get().getAccountUuid().equals(accountUuid)) { + throw new SQLException("Character not found for account: " + characterId); + } + + PlayerSessionService.AccountSession session = sessions.requireSession(accountUuid); + if (session.activeCharacter() != null + && !session.activeCharacter().getCharacterId().equals(characterId)) { + persistActiveCharacter(session, store, ref); + } + + PlayerProfile profile = found.get(); + PlayerAccount account = session.account(); + profile.applyAccountSession(account); + profile.touch(); + + InventoryUtils.clear(ref, store); + inventory.loadToPlayer(characterId, store, ref); + + account.setActiveCharacterId(characterId); + account.touch(); + accounts.setActiveCharacter(accountUuid, characterId); + accounts.save(account); + + sessions.activateCharacter(accountUuid, profile); + completeLogin(playerRef, ref, store, profile); + } + + public void persistActiveCharacter( + @Nonnull PlayerSessionService.AccountSession session, + @Nonnull Store store, + @Nonnull Ref ref) { + PlayerProfile active = session.activeCharacter(); + if (active == null) { + return; + } + try { + active.touch(); + characters.save(active); + inventory.saveFromPlayer(active.getCharacterId(), store, ref); + } catch (SQLException e) { + logger.at(Level.WARNING).log( + "Failed to persist character %s: %s", active.getCharacterId(), e.getMessage()); + } + } + + private void completeLogin( + @Nonnull PlayerRef playerRef, + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull PlayerProfile profile) { + var bootstrap = plugin.getBootstrap(); + if (bootstrap == null) { + return; + } + + OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class); + Player player = store.getComponent(ref, Player.getComponentType()); + if (player != null) { + onlinePlayers.add(new OnlinePlayer( + profile.getUuid(), playerRef, ref, player.getWorld())); + player.getPageManager().setPage(ref, store, Page.None); + } + + playerRef.sendMessage(Message.raw(String.format( + "Bienvenue — %s | Niveau %d | Race: %s", + profile.getDisplayName(), + profile.getLevel(), + RaceCatalog.byId(profile.getRaceId()).displayName()))); + playerRef.sendMessage(Message.raw( + "Commandes : /mmorpg menu | /mmorpg character | /mmorpg class info")); + + ClassService classService = bootstrap.getRegistry().require(ClassService.class); + AbilityBarService abilityBar = bootstrap.getRegistry().require(AbilityBarService.class); + MmorpgScheduler scheduler = bootstrap.getRegistry().require(MmorpgScheduler.class); + if (classService.hasClass(profile)) { + scheduler.runLater(() -> abilityBar.sync(profile.getUuid(), profile), 5_000L); + } + } + + @Nullable + private static String normalizeName(@Nonnull String rawName) { + String name = rawName.trim(); + if (name.length() < MIN_NAME_LENGTH || name.length() > MAX_NAME_LENGTH) { + return null; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == ' ') { + continue; + } + return null; + } + return name; + } + + public record CharacterCreationResult( + boolean ok, + @Nullable PlayerProfile profile, + @Nullable String errorMessage) { + + static CharacterCreationResult success(@Nonnull PlayerProfile profile) { + return new CharacterCreationResult(true, profile, null); + } + + static CharacterCreationResult invalidName() { + return new CharacterCreationResult( + false, + null, + String.format( + Locale.FRENCH, + "Nom invalide (%d–%d caractères, lettres/chiffres/espaces/_/-).", + MIN_NAME_LENGTH, + MAX_NAME_LENGTH)); + } + + static CharacterCreationResult duplicateName(@Nonnull String name) { + return new CharacterCreationResult(false, null, "Un personnage nommé « " + name + " » existe déjà."); + } + + static CharacterCreationResult limitReached(int max) { + return new CharacterCreationResult( + false, null, "Limite de " + max + " personnages atteinte."); + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java index 2a8d731..4ad7347 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerInfoView.java @@ -36,7 +36,7 @@ public final class PlayerInfoView { @Nonnull public static List entries(@Nonnull PlayerProfile profile) { List entries = new ArrayList<>(); - entries.add(new Entry("Nom", profile.getDisplayName())); + entries.add(new Entry("Personnage", profile.getDisplayName())); entries.add(new Entry("Niveau", String.valueOf(profile.getLevel()))); entries.add(new Entry("Expérience", String.valueOf(profile.getExperience()))); entries.add(new Entry("Argent", profile.getMoney() + " money")); diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java index fb74dc8..2ddb9fb 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/PlayerSessionService.java @@ -3,9 +3,10 @@ package com.disklexar.mmorpg.player; import com.disklexar.mmorpg.MmorpgPlugin; import com.disklexar.mmorpg.core.config.MmorpgConfig; import com.disklexar.mmorpg.core.service.Service; -import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository; +import com.disklexar.mmorpg.persistence.repository.CharacterRepository; +import com.disklexar.mmorpg.persistence.repository.PlayerAccountRepository; +import com.disklexar.mmorpg.player.model.PlayerAccount; import com.disklexar.mmorpg.player.model.PlayerProfile; -import com.disklexar.mmorpg.rpg.race.RaceCatalog; import com.hypixel.hytale.logger.HytaleLogger; import javax.annotation.Nonnull; @@ -18,31 +19,50 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; /** - * Tracks online player sessions and synchronizes profiles with SQLite. + * Tracks online account sessions and synchronizes characters with SQLite. */ public final class PlayerSessionService implements Service { + public record AccountSession( + @Nonnull PlayerAccount account, + @Nullable PlayerProfile activeCharacter, + boolean awaitingCharacterSelection) { + + @Nullable + public PlayerProfile activeCharacter() { + return activeCharacter; + } + + @Nonnull + public PlayerAccount account() { + return account; + } + } + private final MmorpgPlugin plugin; private final MmorpgConfig config; - private final PlayerProfileRepository repository; + private final PlayerAccountRepository accountRepository; + private final CharacterRepository characterRepository; private final HytaleLogger logger; - private final Map sessions = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); private final Map connectedAt = new ConcurrentHashMap<>(); public PlayerSessionService( @Nonnull MmorpgPlugin plugin, @Nonnull MmorpgConfig config, - @Nonnull PlayerProfileRepository repository) { + @Nonnull PlayerAccountRepository accountRepository, + @Nonnull CharacterRepository characterRepository) { this.plugin = plugin; this.config = config; - this.repository = repository; + this.accountRepository = accountRepository; + this.characterRepository = characterRepository; this.logger = plugin.getLogger(); } @Override public void start() { try { - repository.clearConnectedFlags(); + accountRepository.clearConnectedFlags(); } catch (SQLException e) { logger.at(Level.WARNING).log("Failed to reset connection flags: %s", e.getMessage()); } @@ -51,10 +71,14 @@ public final class PlayerSessionService implements Service { @Override public void shutdown() { - for (PlayerProfile profile : sessions.values()) { - accumulatePlaytime(profile.getUuid(), profile); - profile.setConnected(false); - saveQuietly(profile); + for (AccountSession session : sessions.values()) { + accumulatePlaytime(session.account().getUuid()); + PlayerAccount account = session.account(); + account.setConnected(false); + saveAccountQuietly(account); + if (session.activeCharacter() != null) { + saveCharacterQuietly(session.activeCharacter()); + } } sessions.clear(); connectedAt.clear(); @@ -62,61 +86,132 @@ public final class PlayerSessionService implements Service { } /** - * Loads (or creates) a profile and marks the player connected. + * Loads (or creates) the account and opens the character selection flow. */ @Nonnull - public PlayerProfile loadOrCreate(@Nonnull UUID uuid, @Nonnull String displayName) throws SQLException { + public AccountSession beginSession(@Nonnull UUID accountUuid, @Nonnull String displayName) + throws SQLException { long now = System.currentTimeMillis(); - Optional existing = repository.findByUuid(uuid); - PlayerProfile profile; + Optional existing = accountRepository.findByUuid(accountUuid); + PlayerAccount account; if (existing.isPresent()) { - profile = existing.get(); - profile.setDisplayName(displayName); + account = existing.get(); + account.setDisplayName(displayName); } else { - profile = new PlayerProfile(uuid, displayName, now); - profile.setLevel(config.getDefaultLevel()); - profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID); - logger.at(Level.INFO).log("Created new MMORPG profile for %s", displayName); + account = new PlayerAccount(accountUuid, displayName, now); + logger.at(Level.INFO).log("Created new MMORPG account for %s", displayName); } - profile.setConnected(true); - profile.setLastDateConnected(now); - profile.touch(); - sessions.put(uuid, profile); - connectedAt.put(uuid, now); - saveQuietly(profile); - return profile; + account.setConnected(true); + account.setLastDateConnected(now); + account.touch(); + + AccountSession session = new AccountSession(account, null, true); + sessions.put(accountUuid, session); + connectedAt.put(accountUuid, now); + saveAccountQuietly(account); + return session; + } + + public void activateCharacter(@Nonnull UUID accountUuid, @Nonnull PlayerProfile profile) { + AccountSession current = sessions.get(accountUuid); + if (current == null) { + return; + } + profile.applyAccountSession(current.account()); + AccountSession updated = new AccountSession(current.account(), profile, false); + sessions.put(accountUuid, updated); + } + + /** + * Puts the account back into character-selection mode (active character cleared from session). + */ + public void beginCharacterSelection(@Nonnull UUID accountUuid) { + AccountSession current = sessions.get(accountUuid); + if (current == null) { + return; + } + sessions.put(accountUuid, new AccountSession(current.account(), null, true)); + } + + public boolean isAwaitingCharacterSelection(@Nonnull UUID accountUuid) { + AccountSession session = sessions.get(accountUuid); + return session != null && session.awaitingCharacterSelection(); } @Nullable - public PlayerProfile getSession(@Nonnull UUID uuid) { - return sessions.get(uuid); + public PlayerProfile getSession(@Nonnull UUID accountUuid) { + AccountSession session = sessions.get(accountUuid); + if (session == null || session.awaitingCharacterSelection()) { + return null; + } + return session.activeCharacter(); } - public void unload(@Nonnull UUID uuid) { - PlayerProfile profile = sessions.remove(uuid); - if (profile == null) { + @Nullable + public AccountSession getAccountSession(@Nonnull UUID accountUuid) { + return sessions.get(accountUuid); + } + + @Nonnull + public AccountSession requireSession(@Nonnull UUID accountUuid) throws SQLException { + AccountSession session = sessions.get(accountUuid); + if (session == null) { + throw new SQLException("No active session for account: " + accountUuid); + } + return session; + } + + public void unload(@Nonnull UUID accountUuid) { + AccountSession session = sessions.remove(accountUuid); + if (session == null) { return; } - accumulatePlaytime(uuid, profile); - profile.setConnected(false); - profile.touch(); - saveQuietly(profile); - } - - private void accumulatePlaytime(@Nonnull UUID uuid, @Nonnull PlayerProfile profile) { - Long start = connectedAt.remove(uuid); - if (start != null) { - profile.addTimePlay(System.currentTimeMillis() - start); + accumulatePlaytime(accountUuid); + PlayerAccount account = session.account(); + account.setConnected(false); + account.touch(); + saveAccountQuietly(account); + if (session.activeCharacter() != null) { + saveCharacterQuietly(session.activeCharacter()); } } - private void saveQuietly(@Nonnull PlayerProfile profile) { + public void saveActiveCharacter(@Nonnull UUID accountUuid) { + AccountSession session = sessions.get(accountUuid); + if (session == null || session.activeCharacter() == null) { + return; + } + saveCharacterQuietly(session.activeCharacter()); + } + + private void accumulatePlaytime(@Nonnull UUID accountUuid) { + Long start = connectedAt.remove(accountUuid); + AccountSession session = sessions.get(accountUuid); + if (start == null || session == null) { + return; + } + session.account().addTimePlay(System.currentTimeMillis() - start); + if (session.activeCharacter() != null) { + session.activeCharacter().applyAccountSession(session.account()); + } + } + + private void saveAccountQuietly(@Nonnull PlayerAccount account) { try { - repository.save(profile); + accountRepository.save(account); } catch (SQLException e) { logger.at(Level.WARNING).log( - "Failed to save profile for %s: %s", profile.getUuid(), e.getMessage()); + "Failed to save account for %s: %s", account.getUuid(), e.getMessage()); + } + } + + private void saveCharacterQuietly(@Nonnull PlayerProfile profile) { + try { + characterRepository.save(profile); + } catch (SQLException e) { + logger.at(Level.WARNING).log( + "Failed to save character for %s: %s", profile.getCharacterId(), e.getMessage()); } } } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerAccount.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerAccount.java new file mode 100644 index 0000000..b7f8d0d --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerAccount.java @@ -0,0 +1,95 @@ +package com.disklexar.mmorpg.player.model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Persistent MMORPG account (one Hytale player UUID). + */ +public final class PlayerAccount { + + private final UUID uuid; + private String displayName; + @Nullable + private UUID activeCharacterId; + private boolean connected; + private long totalTimePlay; + private long lastDateConnected; + private final long dateCreation; + private long updatedAt; + + public PlayerAccount(@Nonnull UUID uuid, @Nonnull String displayName, long dateCreation) { + this.uuid = uuid; + this.displayName = displayName; + this.dateCreation = dateCreation; + this.updatedAt = dateCreation; + } + + @Nonnull + public UUID getUuid() { + return uuid; + } + + @Nonnull + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(@Nonnull String displayName) { + this.displayName = displayName; + } + + @Nullable + public UUID getActiveCharacterId() { + return activeCharacterId; + } + + public void setActiveCharacterId(@Nullable UUID activeCharacterId) { + this.activeCharacterId = activeCharacterId; + } + + public boolean isConnected() { + return connected; + } + + public void setConnected(boolean connected) { + this.connected = connected; + } + + public long getTotalTimePlay() { + return totalTimePlay; + } + + public void setTotalTimePlay(long totalTimePlay) { + this.totalTimePlay = totalTimePlay; + } + + public void addTimePlay(long deltaMillis) { + this.totalTimePlay += Math.max(0L, deltaMillis); + } + + public long getLastDateConnected() { + return lastDateConnected; + } + + public void setLastDateConnected(long lastDateConnected) { + this.lastDateConnected = lastDateConnected; + } + + public long getDateCreation() { + return dateCreation; + } + + public long getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(long updatedAt) { + this.updatedAt = updatedAt; + } + + public void touch() { + this.updatedAt = System.currentTimeMillis(); + } +} diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java index 4193556..5494eb2 100644 --- a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/player/model/PlayerProfile.java @@ -7,15 +7,12 @@ import java.util.Set; import java.util.UUID; /** - * Persistent MMORPG player profile. - *

- * Mutable so gameplay services can update progression in-session; the - * {@code updatedAt} timestamp is refreshed by {@link #touch()} on every change and the whole - * profile is flushed to SQLite on unload / shutdown. + * Persistent MMORPG character profile (one playable persona per account). */ public final class PlayerProfile { - private final UUID uuid; + private final UUID characterId; + private final UUID accountUuid; private String displayName; private int level; private long experience; @@ -37,16 +34,32 @@ public final class PlayerProfile { private final long dateCreation; private long updatedAt; - public PlayerProfile(@Nonnull UUID uuid, @Nonnull String displayName, long dateCreation) { - this.uuid = uuid; + public PlayerProfile( + @Nonnull UUID characterId, + @Nonnull UUID accountUuid, + @Nonnull String displayName, + long dateCreation) { + this.characterId = characterId; + this.accountUuid = accountUuid; this.displayName = displayName; this.dateCreation = dateCreation; this.updatedAt = dateCreation; } + /** Account UUID (Hytale player id) — used for session lookup. */ @Nonnull public UUID getUuid() { - return uuid; + return accountUuid; + } + + @Nonnull + public UUID getCharacterId() { + return characterId; + } + + @Nonnull + public UUID getAccountUuid() { + return accountUuid; } @Nonnull @@ -168,7 +181,13 @@ public final class PlayerProfile { this.updatedAt = updatedAt; } - /** Refreshes {@link #updatedAt} to now; call after mutating any field. */ + /** Copies account-level session fields onto this character view. */ + public void applyAccountSession(@Nonnull PlayerAccount account) { + setConnected(account.isConnected()); + setTotalTimePlay(account.getTotalTimePlay()); + setLastDateConnected(account.getLastDateConnected()); + } + public void touch() { this.updatedAt = System.currentTimeMillis(); } diff --git a/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java new file mode 100644 index 0000000..4361cbe --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/java/com/disklexar/mmorpg/ui/CharacterSelectPage.java @@ -0,0 +1,247 @@ +package com.disklexar.mmorpg.ui; + +import com.disklexar.mmorpg.player.CharacterService; +import com.disklexar.mmorpg.player.model.PlayerProfile; +import com.disklexar.mmorpg.rpg.clazz.ClassCatalog; +import com.disklexar.mmorpg.rpg.clazz.PlayerClass; +import com.disklexar.mmorpg.rpg.race.RaceCatalog; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.hypixel.hytale.codec.builder.BuilderCodec; +import com.hypixel.hytale.component.Ref; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime; +import com.hypixel.hytale.protocol.packets.interface_.CustomUIEventBindingType; +import com.hypixel.hytale.server.core.entity.entities.player.pages.InteractiveCustomUIPage; +import com.hypixel.hytale.server.core.ui.builder.EventData; +import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder; +import com.hypixel.hytale.server.core.ui.builder.UIEventBuilder; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.sql.SQLException; +import java.util.List; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Mandatory character selector shown on join before gameplay begins. + *

+ * Character rows are predefined in {@code MmorpgCharacterSelect.ui} — {@code appendInline} + * only supports simple widgets (e.g. {@code Label}), not nested {@code Group}/{@code TextButton}. + */ +public final class CharacterSelectPage extends InteractiveCustomUIPage { + + private static final String DOCUMENT = "Pages/MmorpgCharacterSelect.ui"; + private static final int SLOT_COUNT = 5; + private static final Logger LOGGER = Logger.getLogger(CharacterSelectPage.class.getName()); + + private final PlayerRef playerRef; + private final UUID accountUuid; + private final CharacterService characters; + private final int maxCharacters; + + public CharacterSelectPage( + @Nonnull PlayerRef playerRef, + @Nonnull UUID accountUuid, + @Nonnull CharacterService characters, + int maxCharacters) { + super(playerRef, CustomPageLifetime.CantClose, UiEvent.CODEC); + this.playerRef = playerRef; + this.accountUuid = accountUuid; + this.characters = characters; + this.maxCharacters = Math.min(maxCharacters, SLOT_COUNT); + } + + @Override + public void build( + @Nonnull Ref ref, + @Nonnull UICommandBuilder ui, + @Nonnull UIEventBuilder events, + @Nonnull Store store) { + ui.append(DOCUMENT); + ui.set("#Title.Text", escape("Sélection du personnage")); + ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername())); + bindActions(events); + renderCharacterList(ui, events, loadCharacters()); + } + + @Override + public void handleDataEvent( + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull String raw) { + ParsedEvent event = parseEvent(raw); + if (event == null) { + return; + } + + if ("refresh".equals(event.action())) { + refresh(ref, store); + return; + } + + if ("create".equals(event.action())) { + handleCreate(ref, store); + return; + } + + if ("select".equals(event.action()) && event.characterId() != null) { + handleSelect(ref, store, event.characterId()); + } + } + + private void handleCreate(@Nonnull Ref ref, @Nonnull Store store) { + try { + List existing = characters.listCharacters(accountUuid); + if (existing.size() >= maxCharacters) { + setStatus(ref, store, "Limite de " + maxCharacters + " personnages atteinte."); + return; + } + String name = characters.nextDefaultName(accountUuid); + CharacterService.CharacterCreationResult result = characters.createCharacter(accountUuid, name); + if (!result.ok()) { + setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Creation impossible."); + return; + } + refresh(ref, store); + } catch (SQLException e) { + LOGGER.log(Level.WARNING, "Character creation failed", e); + setStatus(ref, store, "Erreur lors de la creation du personnage."); + } + } + + private void handleSelect( + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull UUID characterId) { + try { + characters.activateCharacter(playerRef, ref, store, accountUuid, characterId); + } catch (SQLException e) { + LOGGER.log(Level.WARNING, "Character selection failed", e); + setStatus(ref, store, "Impossible de charger ce personnage."); + } + } + + private void refresh(@Nonnull Ref ref, @Nonnull Store store) { + UICommandBuilder ui = new UICommandBuilder(); + UIEventBuilder events = new UIEventBuilder(); + bindActions(events); + renderCharacterList(ui, events, loadCharacters()); + sendUpdate(ui, events, false); + } + + private void setStatus( + @Nonnull Ref ref, + @Nonnull Store store, + @Nonnull String message) { + UICommandBuilder ui = new UICommandBuilder(); + UIEventBuilder events = new UIEventBuilder(); + bindActions(events); + renderCharacterList(ui, events, loadCharacters()); + ui.set("#FooterHint.Text", escape(message)); + sendUpdate(ui, events, false); + } + + @Nonnull + private List loadCharacters() { + try { + return characters.listCharacters(accountUuid); + } catch (SQLException e) { + LOGGER.log(Level.WARNING, "Failed to list characters", e); + return List.of(); + } + } + + private void renderCharacterList( + @Nonnull UICommandBuilder ui, + @Nonnull UIEventBuilder events, + @Nonnull List profiles) { + for (int slot = 0; slot < SLOT_COUNT; slot++) { + ui.set("#CharRow" + slot + ".Visible", false); + } + + if (profiles.isEmpty()) { + ui.set("#EmptyLabel.Visible", true); + ui.set("#FooterHint.Text", escape("Cliquez sur « Nouveau personnage »")); + return; + } + + ui.set("#EmptyLabel.Visible", false); + ui.set("#FooterHint.Text", escape(profiles.size() + " / " + maxCharacters + " personnages")); + + int visible = Math.min(profiles.size(), maxCharacters); + for (int slot = 0; slot < visible; slot++) { + PlayerProfile profile = profiles.get(slot); + ui.set("#CharRow" + slot + ".Visible", true); + ui.set("#CharName" + slot + ".Text", escape(formatSummary(profile))); + events.addEventBinding( + CustomUIEventBindingType.Activating, + "#CharSelect" + slot, + EventData.of("Action", "select") + .append("CharacterId", profile.getCharacterId().toString()), + false); + } + } + + private void bindActions(@Nonnull UIEventBuilder events) { + events.addEventBinding( + CustomUIEventBindingType.Activating, + "#CreateCharacterButton", + EventData.of("Action", "create"), + false); + events.addEventBinding( + CustomUIEventBindingType.Activating, + "#RefreshButton", + EventData.of("Action", "refresh"), + false); + } + + @Nonnull + private static String formatSummary(@Nonnull PlayerProfile profile) { + PlayerClass playerClass = ClassCatalog.byId(profile.getClassId()); + String className = playerClass == null ? "Sans classe" : playerClass.displayName(); + String raceName = RaceCatalog.byId(profile.getRaceId()).displayName(); + return profile.getDisplayName() + + " - Nv." + profile.getLevel() + + " | " + raceName + + " | " + className; + } + + @Nullable + private static ParsedEvent parseEvent(@Nonnull String raw) { + try { + JsonObject json = JsonParser.parseString(raw).getAsJsonObject(); + String action = json.has("Action") ? json.get("Action").getAsString().toLowerCase() : null; + if (action == null) { + return null; + } + UUID characterId = null; + if (json.has("CharacterId")) { + characterId = UUID.fromString(json.get("CharacterId").getAsString()); + } + return new ParsedEvent(action, characterId); + } catch (RuntimeException ignored) { + return null; + } + } + + @Nonnull + private static String escape(@Nonnull String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private record ParsedEvent(@Nonnull String action, @Nullable UUID characterId) { + } + + public static final class UiEvent { + public static final BuilderCodec CODEC = + BuilderCodec.builder(UiEvent.class, UiEvent::new).build(); + + private UiEvent() { + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/resources/Common/UI/Custom/Pages/MmorpgCharacterSelect.ui b/Hytale/plugins/mmorpg/src/main/resources/Common/UI/Custom/Pages/MmorpgCharacterSelect.ui new file mode 100644 index 0000000..76c193e --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/resources/Common/UI/Custom/Pages/MmorpgCharacterSelect.ui @@ -0,0 +1,153 @@ +@MmorpgBg = #0d0f14(0.98); +@MmorpgPanel = #1a1c20; +@MmorpgAccent = #f5c842; +@MmorpgText = #e8e8ec; +@MmorpgMuted = #878e9c; + +Group { + LayoutMode: Center; + + Group #CharacterSelectRoot { + LayoutMode: Top; + Anchor: (Width: 640, Height: 520); + Background: @MmorpgBg; + Padding: (Full: 20); + + Label #Title { + Style: (FontSize: 22, RenderBold: true, TextColor: @MmorpgAccent, HorizontalAlignment: Center); + Text: "Selection du personnage"; + Anchor: (Bottom: 6); + } + + Label #Subtitle { + Style: (FontSize: 14, TextColor: @MmorpgText, HorizontalAlignment: Center); + Text: "Choisissez un personnage pour entrer en jeu"; + Anchor: (Bottom: 14); + } + + Group #CharacterListHost { + LayoutMode: Top; + FlexWeight: 1; + Background: @MmorpgPanel; + Padding: (Full: 12); + + Label #EmptyLabel { + Style: (FontSize: 14, TextColor: @MmorpgMuted, HorizontalAlignment: Center, Wrap: true); + Text: "Aucun personnage — creez-en un pour commencer."; + Visible: false; + } + + Group #CharRow0 { + LayoutMode: Left; + Padding: (Vertical: 6); + Anchor: (Bottom: 4); + Visible: false; + + Label #CharName0 { + Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); + Text: "Perso 1"; + FlexWeight: 1; + } + + TextButton #CharSelect0 { + Text: "Jouer"; + Anchor: (Width: 100); + } + } + + Group #CharRow1 { + LayoutMode: Left; + Padding: (Vertical: 6); + Anchor: (Bottom: 4); + Visible: false; + + Label #CharName1 { + Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); + Text: "Perso 2"; + FlexWeight: 1; + } + + TextButton #CharSelect1 { + Text: "Jouer"; + Anchor: (Width: 100); + } + } + + Group #CharRow2 { + LayoutMode: Left; + Padding: (Vertical: 6); + Anchor: (Bottom: 4); + Visible: false; + + Label #CharName2 { + Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); + Text: "Perso 3"; + FlexWeight: 1; + } + + TextButton #CharSelect2 { + Text: "Jouer"; + Anchor: (Width: 100); + } + } + + Group #CharRow3 { + LayoutMode: Left; + Padding: (Vertical: 6); + Anchor: (Bottom: 4); + Visible: false; + + Label #CharName3 { + Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); + Text: "Perso 4"; + FlexWeight: 1; + } + + TextButton #CharSelect3 { + Text: "Jouer"; + Anchor: (Width: 100); + } + } + + Group #CharRow4 { + LayoutMode: Left; + Padding: (Vertical: 6); + Anchor: (Bottom: 4); + Visible: false; + + Label #CharName4 { + Style: (FontSize: 15, TextColor: @MmorpgText, Wrap: true); + Text: "Perso 5"; + FlexWeight: 1; + } + + TextButton #CharSelect4 { + Text: "Jouer"; + Anchor: (Width: 100); + } + } + } + + Group #Actions { + LayoutMode: Left; + Anchor: (Top: 12); + + TextButton #CreateCharacterButton { + Text: "Nouveau personnage"; + FlexWeight: 1; + Anchor: (Right: 8); + } + + TextButton #RefreshButton { + Text: "Actualiser"; + FlexWeight: 1; + } + } + + Label #FooterHint { + Style: (FontSize: 11, TextColor: @MmorpgMuted, HorizontalAlignment: Center); + Text: "Vous devez selectionner un personnage pour jouer"; + Anchor: (Top: 10); + } + } +} diff --git a/Hytale/plugins/mmorpg/src/main/resources/config.json b/Hytale/plugins/mmorpg/src/main/resources/config.json index 1640ec2..efd99ad 100644 --- a/Hytale/plugins/mmorpg/src/main/resources/config.json +++ b/Hytale/plugins/mmorpg/src/main/resources/config.json @@ -4,6 +4,7 @@ "MaxPlayers": 500, "BaseXpPerLevel": 100, "KillExperience": 10, + "MaxCharactersPerAccount": 5, "Database": { "FileName": "mmorpg.db", "UseMonorepoRoot": true diff --git a/Hytale/plugins/mmorpg/src/main/resources/db/migrations/004_characters.sql b/Hytale/plugins/mmorpg/src/main/resources/db/migrations/004_characters.sql new file mode 100644 index 0000000..fad4ff8 --- /dev/null +++ b/Hytale/plugins/mmorpg/src/main/resources/db/migrations/004_characters.sql @@ -0,0 +1,67 @@ +-- Multi-character support: characters + per-character inventory. +-- player_profiles becomes the account row, RPG data moves to characters. + +ALTER TABLE player_profiles ADD COLUMN active_character_id TEXT; + +CREATE TABLE IF NOT EXISTS characters ( + id TEXT PRIMARY KEY, + account_uuid TEXT NOT NULL, + name TEXT NOT NULL, + level INTEGER NOT NULL DEFAULT 1, + experience INTEGER NOT NULL DEFAULT 0, + class_id TEXT, + powers TEXT NOT NULL DEFAULT '[]', + jobs TEXT NOT NULL DEFAULT '[]', + guild_id TEXT, + group_id TEXT, + money INTEGER NOT NULL DEFAULT 0, + race_id TEXT NOT NULL DEFAULT 'human', + date_creation INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (account_uuid) REFERENCES player_profiles(uuid) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_characters_account ON characters(account_uuid); +CREATE UNIQUE INDEX IF NOT EXISTS idx_characters_account_name ON characters(account_uuid, name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS character_inventory ( + character_id TEXT NOT NULL, + section_id INTEGER NOT NULL, + slot_index INTEGER NOT NULL, + item_id TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + durability REAL, + max_durability REAL, + metadata TEXT, + PRIMARY KEY (character_id, section_id, slot_index), + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +-- Migrate existing single-profile players: account uuid becomes the first character id. +INSERT INTO characters ( + id, account_uuid, name, level, experience, class_id, powers, jobs, + guild_id, group_id, money, race_id, date_creation, updated_at +) +SELECT + uuid, + uuid, + display_name, + level, + experience, + class_id, + powers, + jobs, + guild_id, + group_id, + money, + race_id, + COALESCE(NULLIF(date_creation, 0), created_at), + updated_at +FROM player_profiles +WHERE NOT EXISTS ( + SELECT 1 FROM characters WHERE characters.id = player_profiles.uuid +); + +UPDATE player_profiles +SET active_character_id = uuid +WHERE active_character_id IS NULL; diff --git a/Webapp/README.md b/Webapp/README.md index 36f90bb..1151aef 100644 --- a/Webapp/README.md +++ b/Webapp/README.md @@ -47,7 +47,7 @@ Site accessible sur [http://localhost:3000](http://localhost:3000). ## Connexion joueur 1. Rejoindre le serveur Hytale (création automatique du profil) -2. En jeu : `/mmorpg password MonMotDePasse` (6–72 caractères) +2. En jeu : `/mmorpg password MonMotDePasse` (3–72 caractères) 3. Sur le site : **Mon profil** → pseudo + mot de passe Pour modifier le mot de passe, réutilisez la commande en jeu. diff --git a/Webapp/package-lock.json b/Webapp/package-lock.json index 5f7c9b5..d51965b 100644 --- a/Webapp/package-lock.json +++ b/Webapp/package-lock.json @@ -8,15 +8,43 @@ "name": "disklexar-web", "version": "0.1.0", "dependencies": { + "bcrypt": "^5.1.1", "better-sqlite3": "^11.8.1", "dotenv": "^16.4.7", "ejs": "^3.1.10", - "express": "^4.21.2" + "express": "^4.21.2", + "express-session": "^1.18.1" }, "engines": { "node": ">=20" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -30,6 +58,70 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -68,6 +160,20 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/better-sqlite3": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", @@ -200,6 +306,27 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -269,6 +396,12 @@ "node": ">=4.0.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -344,6 +477,12 @@ "node": ">=0.10.0" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -462,6 +601,29 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -519,6 +681,36 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -528,6 +720,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -571,6 +784,49 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -595,6 +851,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -627,6 +889,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -659,6 +957,17 @@ ], "license": "BSD-3-Clause" }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -680,6 +989,15 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -697,6 +1015,30 @@ "node": ">=10" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -799,6 +1141,52 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -838,6 +1226,69 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -862,6 +1313,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -880,6 +1340,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -957,6 +1426,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1010,6 +1488,22 @@ "node": ">= 6" } }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1093,6 +1587,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1171,6 +1671,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/simple-concat": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", @@ -1234,6 +1740,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1243,6 +1775,24 @@ "node": ">=0.10.0" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -1271,6 +1821,15 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1280,6 +1839,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1305,6 +1870,18 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1338,11 +1915,42 @@ "node": ">= 0.8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" } } } diff --git a/Webapp/src/auth.js b/Webapp/src/auth.js index 0286f63..9f3da86 100644 --- a/Webapp/src/auth.js +++ b/Webapp/src/auth.js @@ -1,6 +1,6 @@ const bcrypt = require("bcrypt"); -const MIN_PASSWORD_LENGTH = 6; +const MIN_PASSWORD_LENGTH = 3; const MAX_PASSWORD_LENGTH = 72; async function verifyPassword(plainPassword, passwordHash) { diff --git a/Webapp/src/db.js b/Webapp/src/db.js index 7f70d09..e5d340d 100644 --- a/Webapp/src/db.js +++ b/Webapp/src/db.js @@ -43,6 +43,17 @@ function queryOne(sql, params = []) { return connection.prepare(sql).get(...params) ?? null; } +function hasCharactersTable() { + const connection = getDb(); + if (!connection) { + return false; + } + const row = queryOne( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'characters'", + ); + return Boolean(row); +} + function getDashboardStats() { const connection = getDb(); if (!connection) { @@ -55,14 +66,23 @@ function getDashboardStats() { }; } - const row = queryOne(` - SELECT - COUNT(*) AS totalPlayers, - COALESCE(SUM(CASE WHEN is_connected = 1 THEN 1 ELSE 0 END), 0) AS onlinePlayers, - COALESCE(SUM(money), 0) AS totalMoney, - COALESCE(AVG(level), 0) AS averageLevel - FROM player_profiles - `); + const usesCharacters = hasCharactersTable(); + const row = usesCharacters + ? queryOne(` + SELECT + (SELECT COUNT(*) FROM player_profiles) AS totalPlayers, + (SELECT COUNT(*) FROM player_profiles WHERE is_connected = 1) AS onlinePlayers, + COALESCE((SELECT SUM(money) FROM characters), 0) AS totalMoney, + COALESCE((SELECT AVG(level) FROM characters), 0) AS averageLevel + `) + : queryOne(` + SELECT + COUNT(*) AS totalPlayers, + COALESCE(SUM(CASE WHEN is_connected = 1 THEN 1 ELSE 0 END), 0) AS onlinePlayers, + COALESCE(SUM(money), 0) AS totalMoney, + COALESCE(AVG(level), 0) AS averageLevel + FROM player_profiles + `); const groups = queryOne(`SELECT COUNT(*) AS count FROM groups`); @@ -76,6 +96,22 @@ function getDashboardStats() { } function getOnlinePlayers() { + if (hasCharactersTable()) { + return queryAll(` + SELECT + p.uuid, + COALESCE(c.name, p.display_name) AS display_name, + COALESCE(c.level, 1) AS level, + c.class_id, + c.race_id, + COALESCE(c.money, 0) AS money, + p.last_date_connected + FROM player_profiles p + LEFT JOIN characters c ON c.id = p.active_character_id + WHERE p.is_connected = 1 + ORDER BY display_name COLLATE NOCASE ASC + `); + } return queryAll(` SELECT uuid, display_name, level, class_id, race_id, money, last_date_connected FROM player_profiles @@ -85,6 +121,23 @@ function getOnlinePlayers() { } function getRecentPlayers(limit = 8) { + if (hasCharactersTable()) { + return queryAll( + ` + SELECT + p.uuid, + COALESCE(c.name, p.display_name) AS display_name, + COALESCE(c.level, 1) AS level, + p.is_connected, + p.last_date_connected + FROM player_profiles p + LEFT JOIN characters c ON c.id = p.active_character_id + ORDER BY p.last_date_connected DESC + LIMIT ? + `, + [limit], + ); + } return queryAll( ` SELECT uuid, display_name, level, is_connected, last_date_connected @@ -97,6 +150,26 @@ function getRecentPlayers(limit = 8) { } function getAllPlayers() { + if (hasCharactersTable()) { + return queryAll(` + SELECT + c.id AS character_id, + p.uuid, + c.name AS display_name, + c.level, + c.experience, + c.class_id, + c.race_id, + c.money, + p.is_connected, + p.total_time_play, + p.last_date_connected, + c.date_creation + FROM characters c + JOIN player_profiles p ON p.uuid = c.account_uuid + ORDER BY c.name COLLATE NOCASE ASC + `); + } return queryAll(` SELECT uuid, @@ -116,6 +189,35 @@ function getAllPlayers() { } function getPlayerByUuid(uuid) { + if (hasCharactersTable()) { + return queryOne( + ` + SELECT + p.uuid, + COALESCE(c.name, p.display_name) AS display_name, + COALESCE(c.level, 1) AS level, + COALESCE(c.experience, 0) AS experience, + c.class_id, + c.powers, + c.jobs, + c.guild_id, + c.group_id, + p.is_connected, + p.total_time_play, + p.last_date_connected, + COALESCE(c.date_creation, p.date_creation) AS date_creation, + COALESCE(c.money, 0) AS money, + COALESCE(c.race_id, 'human') AS race_id, + p.created_at, + COALESCE(c.updated_at, p.updated_at) AS updated_at, + c.id AS character_id + FROM player_profiles p + LEFT JOIN characters c ON c.id = p.active_character_id + WHERE p.uuid = ? + `, + [uuid], + ); + } return queryOne( ` SELECT