selector
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-06 23:28:59 +02:00
parent 48a1b900c7
commit 211779785d
29 changed files with 2679 additions and 323 deletions
@@ -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);
});
@@ -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);
@@ -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<EntityStore> store = player.world().getEntityStore().getStore();
Ref<EntityStore> ref = player.entityRef();
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
if (movement == null) {
Ref<EntityStore> 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<EntityStore> 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<EntityStore> resolveEntityRef(@Nonnull World world, @Nonnull UUID playerUuid) {
for (PlayerRef playerRef : world.getPlayerRefs()) {
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
continue;
}
Ref<EntityStore> entityRef = playerRef.getReference();
if (entityRef != null && entityRef.isValid()) {
return entityRef;
}
}
return null;
}
}
@@ -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 <liste|creer|choisir|selecteur>}
*/
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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<PlayerProfile> profiles = characters.listCharacters(accountUuid);
if (profiles.isEmpty()) {
context.sendMessage(Message.raw("Aucun personnage. Utilisez /mmorpg character creer <nom>."));
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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<String> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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."));
}
});
}
}
}
@@ -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));
}
@@ -32,6 +32,7 @@ public final class MmorpgHelpCommand extends AbstractAsyncCommand {
/mmorpg power <recevoir|retirer|info> [pouvoir]
/mmorpg job <rejoindre|quitter|info> [metier]
/mmorpg group <inviter|accepter|quitter|kick|info> [joueur]
/mmorpg character <liste|creer|choisir|selecteur> [nom]
/mmorpg ability <1|2|3> — utiliser une capacité de classe
/mmorpg password <mdp> — mot de passe pour le site web Orion.ovh
(ou utilisez directement les touches « Use ability 1/2/3 » en jeu)
@@ -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;
@@ -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;
}
@@ -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<EntityStore> 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<EntityStore> entityRef = playerRef.getReference();
if (session != null
&& session.activeCharacter() != null
&& online != null
&& entityRef != null
&& entityRef.isValid()) {
Store<EntityStore> 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<EntityStore> entityRef,
@Nonnull Store<EntityStore> 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
@@ -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() {
@@ -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<InventorySlotRecord> 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<InventorySlotRecord> 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<InventorySlotRecord> 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();
}
}
@@ -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<List<String>>() {
}.getType();
private final DatabaseManager databaseManager;
public CharacterRepository(@Nonnull DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
@Nonnull
public Optional<PlayerProfile> findById(@Nonnull UUID characterId) throws SQLException {
return findByQuery("WHERE id = ?", characterId.toString()).stream().findFirst();
}
@Nonnull
public List<PlayerProfile> 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<PlayerProfile> 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<PlayerProfile> 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<String> parseList(@Nullable String json) {
if (json == null || json.isBlank()) {
return List.of();
}
List<String> parsed = GSON.fromJson(json, STRING_LIST);
return parsed != null ? parsed : List.of();
}
@Nonnull
private Connection connection() {
return databaseManager.getConnection();
}
}
@@ -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<PlayerAccount> 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<PlayerAuthRecord> 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();
}
}
@@ -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<List<String>>() {
}.getType();
private final DatabaseManager databaseManager;
public PlayerProfileRepository(@Nonnull DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
@Nonnull
public Optional<PlayerProfile> 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<PlayerAuthRecord> 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<String> parseList(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
List<String> parsed = GSON.fromJson(json, STRING_LIST);
return parsed != null ? parsed : List.of();
}
@Nonnull
private Connection connection() {
return databaseManager.getConnection();
}
}
@@ -26,7 +26,8 @@ public final class SchemaInitializer {
private static final List<String> 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');
@@ -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<EntityStore> store,
@Nonnull Ref<EntityStore> ref) throws SQLException {
repository.replaceAll(characterId, captureFromPlayer(store, ref));
}
public void loadToPlayer(
@Nonnull UUID characterId,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) throws SQLException {
applyToPlayer(repository.load(characterId), store, ref);
}
@Nonnull
public List<InventorySlotRecord> captureFromPlayer(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
List<InventorySlotRecord> 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<InventorySlotRecord> slots,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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();
}
}
@@ -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<PlayerProfile> listCharacters(@Nonnull UUID accountUuid) throws SQLException {
return characters.findByAccountUuid(accountUuid);
}
@Nonnull
public Optional<PlayerProfile> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID accountUuid,
@Nonnull UUID characterId) throws SQLException {
Optional<PlayerProfile> 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<EntityStore> store,
@Nonnull Ref<EntityStore> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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.");
}
}
}
@@ -36,7 +36,7 @@ public final class PlayerInfoView {
@Nonnull
public static List<Entry> entries(@Nonnull PlayerProfile profile) {
List<Entry> 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"));
@@ -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<UUID, PlayerProfile> sessions = new ConcurrentHashMap<>();
private final Map<UUID, AccountSession> sessions = new ConcurrentHashMap<>();
private final Map<UUID, Long> 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<PlayerProfile> existing = repository.findByUuid(uuid);
PlayerProfile profile;
Optional<PlayerAccount> 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());
}
}
}
@@ -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();
}
}
@@ -7,15 +7,12 @@ import java.util.Set;
import java.util.UUID;
/**
* Persistent MMORPG player profile.
* <p>
* 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();
}
@@ -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.
* <p>
* 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<CharacterSelectPage.UiEvent> {
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<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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<EntityStore> ref, @Nonnull Store<EntityStore> store) {
try {
List<PlayerProfile> 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<EntityStore> ref,
@Nonnull Store<EntityStore> 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<EntityStore> ref, @Nonnull Store<EntityStore> store) {
UICommandBuilder ui = new UICommandBuilder();
UIEventBuilder events = new UIEventBuilder();
bindActions(events);
renderCharacterList(ui, events, loadCharacters());
sendUpdate(ui, events, false);
}
private void setStatus(
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> 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<PlayerProfile> 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<PlayerProfile> 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<UiEvent> CODEC =
BuilderCodec.builder(UiEvent.class, UiEvent::new).build();
private UiEvent() {
}
}
}
@@ -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);
}
}
}
@@ -4,6 +4,7 @@
"MaxPlayers": 500,
"BaseXpPerLevel": 100,
"KillExperience": 10,
"MaxCharactersPerAccount": 5,
"Database": {
"FileName": "mmorpg.db",
"UseMonorepoRoot": true
@@ -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;