first
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-05 15:00:58 +02:00
commit 3710a28ae0
100 changed files with 7744 additions and 0 deletions
@@ -0,0 +1,148 @@
package com.disklexar.mmorpg;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.bootstrap.ServiceRegistry;
import com.disklexar.mmorpg.combat.ClassAbilityController;
import com.disklexar.mmorpg.combat.CombatBuffService;
import com.disklexar.mmorpg.combat.CombatStateService;
import com.disklexar.mmorpg.combat.PoisonService;
import com.disklexar.mmorpg.combat.system.MinerBlockBreakSystem;
import com.disklexar.mmorpg.combat.system.MmorpgDamageSystem;
import com.disklexar.mmorpg.combat.system.MmorpgDeathSystem;
import com.disklexar.mmorpg.command.MmorpgCommand;
import com.disklexar.mmorpg.economy.EconomyService;
import com.disklexar.mmorpg.events.AbilityPacketFilter;
import com.disklexar.mmorpg.events.PlayerConnectionHandler;
import com.disklexar.mmorpg.interaction.MmorpgCastAbilityInteraction;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.group.GroupService;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.disklexar.mmorpg.ui.PlayerInfoPage;
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.io.adapter.PacketAdapters;
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.server.OpenCustomUIInteraction;
import com.hypixel.hytale.server.core.plugin.JavaPlugin;
import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.logging.Level;
/**
* Main entry point for the Disklexar Hytale MMORPG plugin.
*/
public final class MmorpgPlugin extends JavaPlugin {
private static MmorpgPlugin instance;
private Bootstrap bootstrap;
private ClassAbilityController classAbilityController;
private PlayerConnectionHandler connectionHandler;
private AbilityPacketFilter abilityPacketFilter;
private PacketFilter inboundPacketFilter;
public MmorpgPlugin(@Nonnull JavaPluginInit init) {
super(init);
}
@Nullable
public static MmorpgPlugin get() {
return instance;
}
@Nullable
public Bootstrap getBootstrap() {
return bootstrap;
}
@Nonnull
public ClassAbilityController getClassAbilityController() {
return classAbilityController;
}
@Override
protected void setup() {
instance = this;
classAbilityController = new ClassAbilityController(this);
connectionHandler = new PlayerConnectionHandler(this);
abilityPacketFilter = new AbilityPacketFilter(classAbilityController);
try {
bootstrap = new Bootstrap(this);
bootstrap.initialize();
} catch (Exception e) {
getLogger().at(Level.SEVERE).log("MMORPG bootstrap failed: %s", e.getMessage());
throw new IllegalStateException("Failed to initialize MMORPG plugin", e);
}
getCodecRegistry(Interaction.CODEC).register(
MmorpgCastAbilityInteraction.ID,
MmorpgCastAbilityInteraction.class,
MmorpgCastAbilityInteraction.CODEC);
getCommandRegistry().registerCommand(new MmorpgCommand(this));
getEventRegistry().registerGlobal(PlayerReadyEvent.class, connectionHandler::onPlayerReady);
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, connectionHandler::onPlayerDisconnect);
inboundPacketFilter = PacketAdapters.registerInbound(abilityPacketFilter);
registerGameplaySystems();
getLogger().at(Level.INFO).log("MMORPG plugin setup complete");
}
private void registerGameplaySystems() {
ServiceRegistry registry = bootstrap.getRegistry();
PlayerSessionService sessions = registry.require(PlayerSessionService.class);
CombatStateService combatState = registry.require(CombatStateService.class);
CombatBuffService combatBuffs = registry.require(CombatBuffService.class);
PoisonService poisonService = registry.require(PoisonService.class);
OnlinePlayerRegistry onlinePlayers = registry.require(OnlinePlayerRegistry.class);
ProgressionService progression = registry.require(ProgressionService.class);
GroupService groups = registry.require(GroupService.class);
JobService jobs = registry.require(JobService.class);
EconomyService economy = registry.require(EconomyService.class);
int killExperience = bootstrap.getConfig().getKillExperience();
getEntityStoreRegistry().registerSystem(
new MmorpgDamageSystem(sessions, combatState, combatBuffs, poisonService, onlinePlayers));
getEntityStoreRegistry().registerSystem(
new MmorpgDeathSystem(sessions, progression, groups, jobs, economy, killExperience));
getEntityStoreRegistry().registerSystem(new MinerBlockBreakSystem(sessions, jobs, economy));
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());
}
return new PlayerInfoPage(playerRef, profile);
});
}
@Override
protected void start() {
if (bootstrap != null) {
bootstrap.startServices();
}
getLogger().at(Level.INFO).log("MMORPG plugin started");
}
@Override
protected void shutdown() {
if (inboundPacketFilter != null) {
PacketAdapters.deregisterInbound(inboundPacketFilter);
inboundPacketFilter = null;
}
if (bootstrap != null) {
bootstrap.shutdownServices();
bootstrap = null;
}
instance = null;
getLogger().at(Level.INFO).log("MMORPG plugin shut down");
}
}
@@ -0,0 +1,4 @@
/**
* Public API interfaces for cross-module integration (future extraction to separate plugins).
*/
package com.disklexar.mmorpg.api;
@@ -0,0 +1,122 @@
package com.disklexar.mmorpg.bootstrap;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.AbilityService;
import com.disklexar.mmorpg.combat.CombatBuffService;
import com.disklexar.mmorpg.combat.CombatStateService;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.combat.PassiveEffectService;
import com.disklexar.mmorpg.combat.PoisonService;
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.GroupRepository;
import com.disklexar.mmorpg.persistence.repository.PlayerProfileRepository;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.group.GroupService;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.disklexar.mmorpg.rpg.power.PowerService;
import com.disklexar.mmorpg.rpg.race.RaceService;
import com.disklexar.mmorpg.ui.AbilityBarService;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.util.logging.Level;
/**
* Wires and starts all MMORPG services.
*/
public final class Bootstrap {
private final MmorpgPlugin plugin;
private final HytaleLogger logger;
private final ServiceRegistry registry = new ServiceRegistry();
private MmorpgConfig config;
public Bootstrap(@Nonnull MmorpgPlugin plugin) {
this.plugin = plugin;
this.logger = plugin.getLogger();
}
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);
GroupRepository groupRepository = new GroupRepository(databaseManager);
// Stateless domain services.
ProgressionService progression = new ProgressionService(config.getBaseXpPerLevel());
EconomyService economy = new EconomyService();
ClassService classService = new ClassService();
PowerService powerService = new PowerService();
JobService jobService = new JobService();
RaceService raceService = new RaceService();
CombatStateService combatState = new CombatStateService();
CombatBuffService combatBuffs = new CombatBuffService();
StunService stunService = new StunService();
OnlinePlayerRegistry onlinePlayers = new OnlinePlayerRegistry();
// Session and lifecycle services.
PlayerSessionService sessionService =
new PlayerSessionService(plugin, config, profileRepository);
MmorpgScheduler scheduler = new MmorpgScheduler(plugin);
PoisonService poisonService = new PoisonService(scheduler);
GroupService groupService =
new GroupService(plugin, groupRepository, sessionService, progression);
AbilityService abilityService =
new AbilityService(scheduler, stunService, combatState, combatBuffs);
PassiveEffectService passiveEffects =
new PassiveEffectService(plugin, scheduler, onlinePlayers, sessionService, combatState);
AbilityBarService abilityBarService = new AbilityBarService(
plugin, classService, abilityService, onlinePlayers, sessionService, progression, scheduler);
registry.register(MmorpgConfig.class, config);
registry.register(DatabaseManager.class, databaseManager);
registry.register(PlayerProfileRepository.class, profileRepository);
registry.register(GroupRepository.class, groupRepository);
registry.register(ProgressionService.class, progression);
registry.register(EconomyService.class, economy);
registry.register(ClassService.class, classService);
registry.register(PowerService.class, powerService);
registry.register(JobService.class, jobService);
registry.register(RaceService.class, raceService);
registry.register(CombatStateService.class, combatState);
registry.register(CombatBuffService.class, combatBuffs);
registry.register(PoisonService.class, poisonService);
registry.register(StunService.class, stunService);
registry.register(OnlinePlayerRegistry.class, onlinePlayers);
registry.register(PlayerSessionService.class, sessionService);
registry.register(MmorpgScheduler.class, scheduler);
registry.register(GroupService.class, groupService);
registry.register(AbilityService.class, abilityService);
registry.register(AbilityBarService.class, abilityBarService);
registry.register(PassiveEffectService.class, passiveEffects);
logger.at(Level.INFO).log("MMORPG bootstrap initialized");
}
public void startServices() {
registry.startAll();
}
public void shutdownServices() {
registry.shutdownAll();
}
@Nonnull
public ServiceRegistry getRegistry() {
return registry;
}
@Nonnull
public MmorpgConfig getConfig() {
return config;
}
}
@@ -0,0 +1,52 @@
package com.disklexar.mmorpg.bootstrap;
import com.disklexar.mmorpg.core.service.Service;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Simple service locator for MMORPG components.
*/
public final class ServiceRegistry {
private final Map<Class<?>, Object> services = new HashMap<>();
private final List<Service> lifecycleServices = new ArrayList<>();
public <T> void register(@Nonnull Class<T> type, @Nonnull T instance) {
services.put(type, instance);
if (instance instanceof Service service) {
lifecycleServices.add(service);
}
}
@Nullable
public <T> T get(@Nonnull Class<T> type) {
return type.cast(services.get(type));
}
@Nonnull
public <T> T require(@Nonnull Class<T> type) {
T service = get(type);
if (service == null) {
throw new IllegalStateException("Service not registered: " + type.getName());
}
return service;
}
public void startAll() {
for (Service service : lifecycleServices) {
service.start();
}
}
public void shutdownAll() {
for (int i = lifecycleServices.size() - 1; i >= 0; i--) {
lifecycleServices.get(i).shutdown();
}
}
}
@@ -0,0 +1,747 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.modules.entity.component.HeadRotation;
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
import com.hypixel.hytale.server.core.inventory.ItemStack;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
import com.hypixel.hytale.server.core.modules.physics.component.Velocity;
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 com.hypixel.hytale.server.core.util.TargetUtil;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Executes the active class abilities. Effects are applied with the verified server APIs
* ({@link TargetUtil}, {@link DamageSystems#executeDamage}, {@link Velocity}); exact numbers
* (dash speed, radii, damage) are tuned via {@code config.json}-style constants and may need
* in-game adjustment.
*/
public final class AbilityService {
// Knockback impulse for the forward dash. The vertical component matters a lot: too little and
// ground friction kills the lunge instantly (the previous ~0.5 block issue). Tunable.
private static final double DASH_KNOCKBACK = 16.0;
private static final double DASH_KNOCKBACK_Y = 4.0;
private static final double DASH_RADIUS = 2.5;
private static final long DASH_SCAN_PERIOD = 100L;
private static final int DASH_SCANS = 5;
private static final long STUN_DURATION = 3_000L;
private static final double SPIN_RADIUS = 3.0;
private static final float SPIN_DAMAGE = 3.0f;
private static final long SPIN_DURATION = 3_000L;
private static final long SPIN_PERIOD = 300L;
private static final double SHOUT_RADIUS = 5.0;
private static final double SHOUT_KNOCKBACK = 18.0;
private static final double SHOUT_KNOCKBACK_Y = 4.0;
private static final float SHOUT_STUNNED_BONUS_DAMAGE = 6.0f;
private static final long RESISTANCE_DEBUFF_DURATION = 20_000L;
// --- Archer ---
private static final long RAPID_FIRE_DURATION = 10_000L;
private static final int VOLLEY_ARROWS = 6;
private static final double VOLLEY_SPREAD_DEG = 9.0;
private static final float VOLLEY_ARROW_DAMAGE = 4.0f;
private static final double VOLLEY_RANGE = 18.0;
private static final double VOLLEY_BACK_KNOCKBACK = 12.0;
private static final double VOLLEY_BACK_KNOCKBACK_Y = 3.0;
// --- Mage ---
private static final int FIREBALL_COUNT = 3;
private static final double FIREBALL_SPREAD_DEG = 12.0;
private static final float FIREBALL_DAMAGE = 7.0f;
private static final double FIREBALL_RANGE = 20.0;
private static final double FIREBALL_HIT_RADIUS = 1.6;
private static final double TELEPORT_DISTANCE = 8.0;
private static final double TORNADO_DISTANCE = 15.0;
private static final double TORNADO_RADIUS = 3.5;
private static final float TORNADO_DAMAGE = 4.0f;
private static final double TORNADO_PULL = 6.0;
private static final long TORNADO_DURATION = 4_000L;
private static final long TORNADO_PERIOD = 350L;
// --- Assassin ---
private static final long CAMOUFLAGE_DURATION = 6_000L;
private static final double SHADOWSTEP_RANGE = 12.0;
private static final double SHADOWSTEP_BEHIND = 1.5;
private static final long POISON_BLADE_DURATION = 10_000L;
// Projectile / spell visuals (shipped game particle systems; unknown ids fail silently).
private static final String VFX_ARROW_TRAIL = "Bow_Signature_Projectile_Sparks";
private static final String VFX_ARROW_HIT = "Example_Projectile_Impact";
private static final String VFX_FIRE_TRAIL = "Fire_Charged1";
private static final String VFX_FIRE_HIT = "Impact_Explosion";
private static final String VFX_TELEPORT = "Teleport";
private static final String VFX_TORNADO = "Example_Tornado";
private static final String VFX_TORNADO_HIT = "Impact_Battleaxe_Bash";
private static final String VFX_SMOKE = "Smoke_Black";
private static final String VFX_BUFF = "Test_Cast_Buff";
private static final String SFX_BOW = "SFX_Bow_T1_Shoot";
private static final String SFX_FIRE = "SFX_Staff_Flame_Fireball_Launch";
private static final String SFX_TELEPORT = "SFX_Portal_Neutral";
// Visual / audio asset ids (shipped game assets). See CombatFx.
private static final String VFX_DASH = "Daggers_Signature_Dash";
private static final String VFX_HIT = "Impact_Blade_01";
private static final String VFX_SPIN = "Battleaxe_Signature_Whirlwind";
private static final String VFX_SPIN_HIT = "Impact_Battleaxe_Bash";
private static final String VFX_SHOUT = "Impact_Explosion";
private static final String SFX_ACTIVATE = "SFX_Avatar_Powers_Enable";
private static final String SFX_SWING = "SFX_Golem_Earth_Swing";
private static final String SFX_HIT = "SFX_Golem_Earth_Swing_Impact";
private static final String SFX_SHOUT = "SFX_Deer_Stag_Roar";
private static final String SFX_SHOUT_IMPACT = "SFX_Golem_Earth_Slam_Impact";
// Weapon animation moves (see Server/Item/Animations/{Battleaxe,Longsword}.json).
private static final String ANIM_SET_BATTLEAXE = "Battleaxe";
private static final String ANIM_SET_LONGSWORD = "Longsword";
private static final String ANIM_DASH = "SwingDown";
private static final String ANIM_SHOUT = "GuardBash";
private static final String ANIM_SPIN_BATTLEAXE = "WhirlwindChargedSpin";
private static final String ANIM_SPIN_LONGSWORD_A = "SwingLeft";
private static final String ANIM_SPIN_LONGSWORD_B = "SwingRight";
private final MmorpgScheduler scheduler;
private final StunService stunService;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
private final Map<UUID, Map<String, Long>> cooldowns = new ConcurrentHashMap<>();
public AbilityService(
@Nonnull MmorpgScheduler scheduler,
@Nonnull StunService stunService,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs) {
this.scheduler = scheduler;
this.stunService = stunService;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
}
public long cooldownRemaining(@Nonnull UUID player, @Nonnull String abilityId) {
Map<String, Long> map = cooldowns.get(player);
if (map == null) {
return 0L;
}
Long readyAt = map.get(abilityId);
if (readyAt == null) {
return 0L;
}
return Math.max(0L, readyAt - System.currentTimeMillis());
}
/**
* Attempts to cast {@code ability} for the player. The caller is responsible for verifying the
* player owns the class; this method enforces the weapon requirement and the cooldown.
*/
@Nonnull
public CastResult cast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityDefinition ability) {
return cast(playerRef, world, playerClass, ability, null);
}
/**
* Casts {@code ability}. {@code aimedTarget} is the entity the player is currently looking at
* (supplied by the input-action handler, used by abilities such as the assassin's Shadow Step);
* it may be {@code null} when triggered from the command, in which case the target is resolved
* from the caster's aim.
*/
@Nonnull
public CastResult cast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityDefinition ability,
@Nullable Ref<EntityStore> aimedTarget) {
UUID caster = playerRef.getUuid();
Store<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> casterRef = playerRef.getReference();
if (!hasRequiredWeapon(store, casterRef, playerClass)) {
return CastResult.WRONG_WEAPON;
}
if (cooldownRemaining(caster, ability.id()) > 0) {
return CastResult.ON_COOLDOWN;
}
String weaponSet = resolveWeaponAnimSet(store, casterRef);
switch (ability.id()) {
case ClassCatalog.ABILITY_DASH -> castDash(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_SPIN -> castSpin(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_SHOUT -> castShout(world, store, casterRef, weaponSet);
case ClassCatalog.ABILITY_ARCHER_RAPIDFIRE -> castRapidFire(world, store, casterRef, caster);
case ClassCatalog.ABILITY_ARCHER_VOLLEY -> castVolley(world, store, casterRef);
case ClassCatalog.ABILITY_ARCHER_POWERSHOT -> castPowerShot(world, store, casterRef, caster);
case ClassCatalog.ABILITY_MAGE_FIREBALLS -> castFireballs(world, store, casterRef);
case ClassCatalog.ABILITY_MAGE_TELEPORT -> castTeleport(world, store, casterRef);
case ClassCatalog.ABILITY_MAGE_TORNADO -> castTornado(world, store, casterRef);
case ClassCatalog.ABILITY_ASSASSIN_CAMOUFLAGE -> castCamouflage(world, store, casterRef, playerRef);
case ClassCatalog.ABILITY_ASSASSIN_SHADOWSTEP -> castShadowStep(world, store, casterRef, aimedTarget);
case ClassCatalog.ABILITY_ASSASSIN_POISONBLADE -> castPoisonBlade(world, store, casterRef, caster);
default -> {
return CastResult.UNKNOWN;
}
}
combatState.markCombat(caster);
cooldowns.computeIfAbsent(caster, k -> new ConcurrentHashMap<>())
.put(ability.id(), System.currentTimeMillis() + ability.cooldownMillis());
return CastResult.OK;
}
/**
* Maps the currently held weapon to its animation set id ({@code Battleaxe} / {@code Longsword}),
* so we can trigger the matching swing/whirlwind move. Returns {@code null} if it cannot be
* resolved (the animation step is then skipped, effects still play).
*/
private String resolveWeaponAnimSet(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
try {
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
if (ItemStack.isEmpty(held)) {
return null;
}
String itemId = held.getItemId();
if (itemId == null) {
return null;
}
String lower = itemId.toLowerCase();
if (lower.contains("battleaxe") || (lower.contains("axe") && !lower.contains("pickaxe"))) {
return ANIM_SET_BATTLEAXE;
}
return ANIM_SET_LONGSWORD;
} catch (Throwable t) {
return null;
}
}
private boolean hasRequiredWeapon(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull PlayerClass playerClass) {
try {
ItemStack held = InventoryComponent.getItemInHand(store, casterRef);
if (ItemStack.isEmpty(held)) {
return false;
}
String itemId = held.getItemId();
if (itemId == null) {
return false;
}
for (String weapon : playerClass.weapons()) {
if (itemId.toLowerCase().contains(weapon.toLowerCase())) {
return true;
}
}
return false;
} catch (Throwable t) {
// If the inventory API shape differs at runtime, fail open so the ability is still usable.
return true;
}
}
private void castDash(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
world.execute(() -> {
Vector3d direction = horizontalLookDirection(store, casterRef);
// Player movement is client-authoritative, so push the caster via the engine knockback
// system (syncs to the client) instead of writing Velocity directly.
CombatFx.knockback(store, casterRef, new Vector3d(
direction.x * DASH_KNOCKBACK, DASH_KNOCKBACK_Y, direction.z * DASH_KNOCKBACK));
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
Vector3d pos = transform != null ? transform.getPosition() : new Vector3d();
CombatFx.animation(store, casterRef, weaponSet, ANIM_DASH);
CombatFx.vfx(store, VFX_DASH, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.2f);
CombatFx.sound(store, SFX_SWING, pos);
});
// Stun every enemy the dash passes through over its travel window.
for (int scan = 1; scan <= DASH_SCANS; scan++) {
scheduler.runLater(() -> world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d pos = transform.getPosition();
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(
pos, DASH_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
applyStun(world, store, target);
dealDamage(store, casterRef, target, 2.0f);
CombatFx.vfx(store, VFX_HIT, targetPosition(store, target, pos));
CombatFx.sound(store, SFX_HIT, pos);
}
}), (long) scan * DASH_SCAN_PERIOD);
}
}
private void castSpin(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
String spinAnimA = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_A;
String spinAnimB = ANIM_SET_BATTLEAXE.equals(weaponSet) ? ANIM_SPIN_BATTLEAXE : ANIM_SPIN_LONGSWORD_B;
java.util.concurrent.atomic.AtomicInteger tick = new java.util.concurrent.atomic.AtomicInteger();
var future = scheduler.runRepeating(() -> world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d pos = transform.getPosition();
int n = tick.getAndIncrement();
// Keep the whirlwind spinning visually and audibly for its whole duration. Each puff is
// spawned with a short, explicit lifetime so it follows the player and fully disappears
// when the spin ends (otherwise the looping aura would stay on screen forever).
CombatFx.animation(store, casterRef, weaponSet, (n % 2 == 0) ? spinAnimA : spinAnimB);
CombatFx.vfxTimed(store, VFX_SPIN, pos, 1.0f, (SPIN_PERIOD / 1000.0f) * 1.2f);
CombatFx.sound(store, SFX_SWING, pos, 0.8f, 1.1f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, SPIN_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
dealDamage(store, casterRef, target, SPIN_DAMAGE);
CombatFx.vfx(store, VFX_SPIN_HIT, targetPosition(store, target, pos));
}
}), 0L, SPIN_PERIOD);
scheduler.runLater(() -> future.cancel(false), SPIN_DURATION);
}
private void castShout(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
String weaponSet) {
world.execute(() -> {
TransformComponent casterTransform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (casterTransform == null) {
return;
}
Vector3d origin = casterTransform.getPosition();
// Fallback push direction (caster's facing) for targets standing right on top of the
// player, where origin-to-target has no usable direction.
Vector3d facing = horizontalLookDirection(store, casterRef);
CombatFx.animation(store, casterRef, weaponSet, ANIM_SHOUT);
CombatFx.vfx(store, VFX_SHOUT, origin);
CombatFx.sound(store, SFX_SHOUT, origin, 1.2f, 0.9f);
CombatFx.sound(store, SFX_SHOUT_IMPACT, origin);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(origin, SHOUT_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
TransformComponent targetTransform =
store.getComponent(target, TransformComponent.getComponentType());
if (targetTransform != null) {
Vector3d push = new Vector3d(targetTransform.getPosition()).sub(origin);
push.y = 0;
if (push.lengthSquared() < 1.0e-4) {
// Mob glued to the player: shove it away along the caster's look direction.
push.set(facing.x, 0, facing.z);
}
if (push.lengthSquared() < 1.0e-4) {
push.set(0, 0, 1);
}
push.normalize();
// Client-synced knockback so both players and mobs are actually pushed back.
CombatFx.knockback(store, target, new Vector3d(
push.x * SHOUT_KNOCKBACK, SHOUT_KNOCKBACK_Y, push.z * SHOUT_KNOCKBACK));
}
UUID targetUuid = entityUuid(store, target);
if (targetUuid != null) {
if (stunService.isStunned(targetUuid)) {
dealDamage(store, casterRef, target, SHOUT_STUNNED_BONUS_DAMAGE);
}
// -20% resistance == takes +20% damage for 20s (applied in the damage system).
combatState.applyResistanceDebuff(targetUuid, RESISTANCE_DEBUFF_DURATION);
}
}
});
}
// ------------------------------------------------------------------------------------------
// Archer
// ------------------------------------------------------------------------------------------
private void castRapidFire(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.grantRapidFire(caster, RAPID_FIRE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_BUFF, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 1.4f);
});
}
private void castVolley(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
Vector3d facing = horizontalLookDirection(store, casterRef);
// Bond en arrière.
CombatFx.knockback(store, casterRef, new Vector3d(
-facing.x * VOLLEY_BACK_KNOCKBACK, VOLLEY_BACK_KNOCKBACK_Y, -facing.z * VOLLEY_BACK_KNOCKBACK));
Vector3d eye = eyePosition(store, casterRef);
CombatFx.sound(store, SFX_BOW, eye, 1.0f, 1.1f);
double start = -(VOLLEY_ARROWS - 1) / 2.0;
for (int i = 0; i < VOLLEY_ARROWS; i++) {
Vector3d dir = rotatedHorizontal(facing, (start + i) * VOLLEY_SPREAD_DEG);
fireProjectile(store, casterRef, eye, dir, VOLLEY_RANGE, 1.2,
VOLLEY_ARROW_DAMAGE, VFX_ARROW_TRAIL, VFX_ARROW_HIT);
}
});
}
private void castPowerShot(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.chargePowerShot(caster);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_BUFF, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.8f);
});
}
// ------------------------------------------------------------------------------------------
// Mage
// ------------------------------------------------------------------------------------------
private void castFireballs(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
Vector3d facing = horizontalLookDirection(store, casterRef);
Vector3d eye = eyePosition(store, casterRef);
CombatFx.sound(store, SFX_FIRE, eye, 1.1f, 1.0f);
double start = -(FIREBALL_COUNT - 1) / 2.0;
for (int i = 0; i < FIREBALL_COUNT; i++) {
Vector3d dir = rotatedHorizontal(facing, (start + i) * FIREBALL_SPREAD_DEG);
fireProjectile(store, casterRef, eye, dir, FIREBALL_RANGE, FIREBALL_HIT_RADIUS,
FIREBALL_DAMAGE, VFX_FIRE_TRAIL, VFX_FIRE_HIT);
}
});
}
private void castTeleport(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
world.execute(() -> {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform == null) {
return;
}
Vector3d from = new Vector3d(transform.getPosition());
Vector3d dir = horizontalLookDirection(store, casterRef);
Vector3d to = new Vector3d(from).add(dir.x * TELEPORT_DISTANCE, 0, dir.z * TELEPORT_DISTANCE);
CombatFx.vfx(store, VFX_TELEPORT, from);
CombatFx.sound(store, SFX_TELEPORT, from, 1.0f, 1.0f);
transform.teleportPosition(to);
CombatFx.vfx(store, VFX_TELEPORT, to);
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.2f);
});
}
private void castTornado(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
Vector3d[] center = new Vector3d[1];
world.execute(() -> {
Vector3d eye = eyePosition(store, casterRef);
Vector3d dir = horizontalLookDirection(store, casterRef);
center[0] = new Vector3d(eye).add(dir.x * TORNADO_DISTANCE, -1.0, dir.z * TORNADO_DISTANCE);
CombatFx.sound(store, SFX_FIRE, center[0], 1.3f, 0.7f);
});
var future = scheduler.runRepeating(() -> world.execute(() -> {
if (center[0] == null) {
return;
}
Vector3d pos = center[0];
CombatFx.vfxTimed(store, VFX_TORNADO, pos, 1.5f, (TORNADO_PERIOD / 1000.0f) * 1.3f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(pos, TORNADO_RADIUS, store)) {
if (target.equals(casterRef)) {
continue;
}
Vector3d tpos = targetPosition(store, target, pos);
Vector3d pull = new Vector3d(pos).sub(tpos);
pull.y = 0;
if (pull.lengthSquared() > 1.0e-4) {
pull.normalize();
}
CombatFx.knockback(store, target, new Vector3d(
pull.x * TORNADO_PULL, 2.5, pull.z * TORNADO_PULL));
dealDamage(store, casterRef, target, TORNADO_DAMAGE);
CombatFx.vfx(store, VFX_TORNADO_HIT, tpos);
}
}), 0L, TORNADO_PERIOD);
scheduler.runLater(() -> future.cancel(false), TORNADO_DURATION);
}
// ------------------------------------------------------------------------------------------
// Assassin
// ------------------------------------------------------------------------------------------
private void castCamouflage(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull PlayerRef playerRef) {
UUID caster = playerRef.getUuid();
combatBuffs.markInvisible(caster, CAMOUFLAGE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, VFX_SMOKE, pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.7f);
for (PlayerRef viewer : world.getPlayerRefs()) {
try {
viewer.getHiddenPlayersManager().hidePlayer(caster);
} catch (Throwable ignored) {
// viewer not ready: skip.
}
}
});
scheduler.runLater(() -> world.execute(() -> {
for (PlayerRef viewer : world.getPlayerRefs()) {
try {
viewer.getHiddenPlayersManager().showPlayer(caster);
} catch (Throwable ignored) {
// viewer gone: skip.
}
}
CombatFx.vfx(store, VFX_SMOKE, position(store, casterRef));
}), CAMOUFLAGE_DURATION);
}
private void castShadowStep(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nullable Ref<EntityStore> aimedTarget) {
world.execute(() -> {
Ref<EntityStore> target = aimedTarget;
if (target == null || !target.isValid() || target.equals(casterRef)) {
target = TargetUtil.getTargetEntity(casterRef, (float) SHADOWSTEP_RANGE, store);
}
TransformComponent casterTransform =
store.getComponent(casterRef, TransformComponent.getComponentType());
if (casterTransform == null) {
return;
}
if (target == null || !target.isValid() || target.equals(casterRef)) {
// No target in sight: short blink forward instead of failing outright.
Vector3d dir = horizontalLookDirection(store, casterRef);
Vector3d from = new Vector3d(casterTransform.getPosition());
Vector3d to = new Vector3d(from).add(dir.x * 3.0, 0, dir.z * 3.0);
CombatFx.vfx(store, VFX_TELEPORT, from);
casterTransform.teleportPosition(to);
CombatFx.vfx(store, VFX_TELEPORT, to);
CombatFx.sound(store, SFX_TELEPORT, to, 1.0f, 1.0f);
return;
}
TransformComponent targetTransform =
store.getComponent(target, TransformComponent.getComponentType());
if (targetTransform == null) {
return;
}
Vector3d targetPos = new Vector3d(targetTransform.getPosition());
Vector3d targetFacing = horizontalLookDirection(store, target);
Vector3d behind = new Vector3d(targetPos)
.sub(targetFacing.x * SHADOWSTEP_BEHIND, 0, targetFacing.z * SHADOWSTEP_BEHIND);
Vector3d from = new Vector3d(casterTransform.getPosition());
CombatFx.vfx(store, VFX_SMOKE, from);
casterTransform.teleportPosition(behind);
CombatFx.vfx(store, VFX_TELEPORT, behind);
CombatFx.sound(store, SFX_TELEPORT, behind, 1.0f, 0.9f);
});
}
private void castPoisonBlade(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull UUID caster) {
combatBuffs.grantPoisonBlade(caster, POISON_BLADE_DURATION);
world.execute(() -> {
Vector3d pos = position(store, casterRef);
CombatFx.vfx(store, "Impact_Poison", pos);
CombatFx.sound(store, SFX_ACTIVATE, pos, 1.0f, 0.9f);
});
}
// ------------------------------------------------------------------------------------------
// Shared helpers for the new abilities
// ------------------------------------------------------------------------------------------
/**
* Casts a straight projectile by stepping along {@code dir} from {@code origin}, leaving a
* particle streak and damaging the first entity within {@code hitRadius} of the path. A
* server-side approximation of a real arrow/fireball (no engine projectile entity is spawned),
* but it reads as a projectile and deals damage.
*/
private void fireProjectile(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Vector3d origin,
@Nonnull Vector3d dir,
double range,
double hitRadius,
float damage,
@Nonnull String trailVfx,
@Nonnull String hitVfx) {
Vector3d point = new Vector3d(origin);
Vector3d step = new Vector3d(dir).mul(1.0);
int steps = (int) Math.ceil(range);
for (int i = 0; i < steps; i++) {
point.add(step);
CombatFx.vfxTimed(store, trailVfx, new Vector3d(point), 0.5f, 0.35f);
for (Ref<EntityStore> target : TargetUtil.getAllEntitiesInSphere(point, hitRadius, store)) {
if (target.equals(casterRef)) {
continue;
}
dealDamage(store, casterRef, target, damage);
CombatFx.vfx(store, hitVfx, targetPosition(store, target, point));
return;
}
}
}
@Nonnull
private Vector3d rotatedHorizontal(@Nonnull Vector3d base, double degrees) {
Vector3d v = new Vector3d(base);
v.y = 0;
v.rotateY((float) Math.toRadians(degrees));
if (v.lengthSquared() < 1.0e-4) {
return new Vector3d(0, 0, 1);
}
return v.normalize();
}
@Nonnull
private Vector3d eyePosition(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
return new Vector3d(position(store, ref)).add(0, 1.5, 0);
}
@Nonnull
private Vector3d position(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
TransformComponent transform = store.getComponent(ref, TransformComponent.getComponentType());
return transform != null ? new Vector3d(transform.getPosition()) : new Vector3d();
}
private void applyStun(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> target) {
UUID uuid = entityUuid(store, target);
if (uuid != null) {
if (stunService.isStunned(uuid)) {
return;
}
stunService.stun(uuid, STUN_DURATION);
}
var future = scheduler.runRepeating(() -> world.execute(() -> {
Velocity velocity = store.getComponent(target, Velocity.getComponentType());
if (velocity != null) {
velocity.setZero();
}
}), 0L, 100L);
scheduler.runLater(() -> future.cancel(false), STUN_DURATION);
}
private void dealDamage(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef,
@Nonnull Ref<EntityStore> target,
float amount) {
Damage damage = new Damage(new Damage.EntitySource(casterRef), DamageCause.PHYSICAL, amount);
DamageSystems.executeDamage(target, store, damage);
}
@Nonnull
private Vector3d horizontalLookDirection(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> casterRef) {
// Use the HEAD rotation (precise camera yaw), not the body TransformComponent rotation which
// is coarse/axis-snapped — that snapping made the dash align to X/Z instead of the true aim.
// This mirrors how the engine itself derives a knockback direction (see KnockbackApplyCommand).
Float yaw = null;
HeadRotation head = store.getComponent(casterRef, HeadRotation.getComponentType());
if (head != null && head.getRotation() != null) {
yaw = head.getRotation().yaw();
} else {
TransformComponent transform = store.getComponent(casterRef, TransformComponent.getComponentType());
if (transform != null && transform.getRotation() != null) {
yaw = transform.getRotation().yaw();
}
}
if (yaw == null) {
return new Vector3d(0, 0, 1);
}
Vector3d direction = new Vector3d(0, 0, -1).rotateY(yaw);
direction.y = 0;
if (direction.lengthSquared() < 1.0e-4) {
return new Vector3d(0, 0, 1);
}
return direction.normalize();
}
@Nonnull
private Vector3d targetPosition(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> target,
@Nonnull Vector3d fallback) {
TransformComponent transform = store.getComponent(target, TransformComponent.getComponentType());
return transform != null ? transform.getPosition() : fallback;
}
private UUID entityUuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
public enum CastResult {
OK, ON_COOLDOWN, WRONG_WEAPON, UNKNOWN
}
}
@@ -0,0 +1,119 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.Message;
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;
/**
* Central entry point for routing Ability 1/2/3 inputs to MMORPG class abilities.
* Used by the packet filter, native {@code mmorpg_cast_ability} interaction, and commands.
*/
public final class ClassAbilityController {
private final MmorpgPlugin plugin;
public ClassAbilityController(@Nonnull MmorpgPlugin plugin) {
this.plugin = plugin;
}
/**
* @return {@code true} when the player has an MMORPG class and vanilla ability interactions
* should be suppressed for this slot (even if the cast fails, e.g. wrong weapon).
*/
public boolean shouldSuppressVanilla(@Nonnull PlayerRef playerRef, int slot) {
return resolveAbility(playerRef, slot) != null;
}
/**
* Attempts to cast the class ability bound to {@code slot}.
*
* @return {@code true} if vanilla should be blocked (player has a class with this slot).
*/
public boolean tryCast(
@Nonnull PlayerRef playerRef,
@Nonnull World world,
int slot,
@Nullable Ref<EntityStore> aimedTarget) {
ResolvedAbility resolved = resolveAbility(playerRef, slot);
if (resolved == null) {
return false;
}
AbilityService.CastResult result = resolved.abilities().cast(
playerRef, world, resolved.playerClass(), resolved.ability(), aimedTarget);
switch (result) {
case OK -> playerRef.sendMessage(Message.raw("Capacité : " + resolved.ability().displayName()));
case ON_COOLDOWN -> {
long remaining = resolved.abilities().cooldownRemaining(
playerRef.getUuid(), resolved.ability().id());
playerRef.sendMessage(Message.raw("En recharge (" + (remaining / 1000 + 1) + "s)."));
}
case WRONG_WEAPON -> playerRef.sendMessage(Message.raw(
"Mauvaise arme. Équipez : " + String.join(", ", resolved.playerClass().weapons())));
case UNKNOWN -> playerRef.sendMessage(Message.raw("Capacité inconnue."));
}
return true;
}
public static int slotFor(@Nonnull InteractionType type) {
return switch (type) {
case Ability1 -> 1;
case Ability2 -> 2;
case Ability3 -> 3;
default -> 0;
};
}
public static boolean isClassAbilityType(@Nonnull InteractionType type) {
return slotFor(type) != 0;
}
@Nullable
private ResolvedAbility resolveAbility(@Nonnull PlayerRef playerRef, int slot) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return null;
}
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
ClassService classService = bootstrap.getRegistry().require(ClassService.class);
AbilityService abilities = bootstrap.getRegistry().require(AbilityService.class);
PlayerProfile profile = sessions.getSession(playerRef.getUuid());
if (profile == null) {
return null;
}
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
return null;
}
AbilityDefinition ability = playerClass.abilities().stream()
.filter(a -> a.slot() == slot)
.findFirst()
.orElse(null);
if (ability == null) {
return null;
}
return new ResolvedAbility(playerClass, ability, abilities);
}
private record ResolvedAbility(
PlayerClass playerClass,
AbilityDefinition ability,
AbilityService abilities) {
}
}
@@ -0,0 +1,72 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks the temporary offensive buffs granted by class abilities and read back by the damage
* system when the buffed player lands a basic attack:
* <ul>
* <li>Archer "Tir rapide": a flat outgoing-damage bonus for a window (server-side proxy for
* attack speed, which is otherwise client-authoritative),</li>
* <li>Archer "Tir puissant": a one-shot +200% multiplier on the next basic attack,</li>
* <li>Assassin "Lame Empoisonnée": a window during which every hit applies a stacking poison,</li>
* <li>Assassin "Camouflage": bookkeeping of the current invisibility window.</li>
* </ul>
*/
public final class CombatBuffService {
private final Map<UUID, Long> rapidFireUntil = new ConcurrentHashMap<>();
private final Set<UUID> powerShotCharged = ConcurrentHashMap.newKeySet();
private final Map<UUID, Long> poisonBladeUntil = new ConcurrentHashMap<>();
private final Map<UUID, Long> invisibleUntil = new ConcurrentHashMap<>();
public void grantRapidFire(@Nonnull UUID uuid, long durationMillis) {
rapidFireUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasRapidFire(@Nonnull UUID uuid) {
Long until = rapidFireUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void chargePowerShot(@Nonnull UUID uuid) {
powerShotCharged.add(uuid);
}
public boolean hasPowerShot(@Nonnull UUID uuid) {
return powerShotCharged.contains(uuid);
}
public boolean consumePowerShot(@Nonnull UUID uuid) {
return powerShotCharged.remove(uuid);
}
public void grantPoisonBlade(@Nonnull UUID uuid, long durationMillis) {
poisonBladeUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasPoisonBlade(@Nonnull UUID uuid) {
Long until = poisonBladeUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void markInvisible(@Nonnull UUID uuid, long durationMillis) {
invisibleUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean isInvisible(@Nonnull UUID uuid) {
Long until = invisibleUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void clear(@Nonnull UUID uuid) {
rapidFireUntil.remove(uuid);
powerShotCharged.remove(uuid);
poisonBladeUntil.remove(uuid);
invisibleUntil.remove(uuid);
}
}
@@ -0,0 +1,148 @@
package com.disklexar.mmorpg.combat;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.AnimationSlot;
import com.hypixel.hytale.protocol.ChangeVelocityType;
import com.hypixel.hytale.protocol.SoundCategory;
import com.hypixel.hytale.protocol.VelocityThresholdStyle;
import com.hypixel.hytale.server.core.asset.type.soundevent.config.SoundEvent;
import com.hypixel.hytale.server.core.entity.AnimationUtils;
import com.hypixel.hytale.server.core.entity.knockback.KnockbackComponent;
import com.hypixel.hytale.server.core.modules.splitvelocity.VelocityConfig;
import com.hypixel.hytale.server.core.universe.world.ParticleUtil;
import com.hypixel.hytale.server.core.universe.world.SoundUtil;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Thin, failure-safe wrappers around the verified server effect helpers
* ({@link ParticleUtil}, {@link SoundUtil}, {@link AnimationUtils}). Each call is guarded so a
* missing asset id or a runtime API mismatch can never break the ability or disconnect the player —
* the worst case is simply that one effect is skipped.
* <p>
* Asset ids (particle systems / sound events / weapon animation moves) are taken from the shipped
* game assets, e.g. {@code Battleaxe_Signature_Whirlwind}, {@code Impact_Explosion},
* {@code SFX_Deer_Stag_Roar}, animation move {@code WhirlwindChargedSpin}.
*/
public final class CombatFx {
private CombatFx() {
}
/** Spawns a world particle system (broadcast to nearby players) at a position. */
public static void vfx(
@Nonnull Store<EntityStore> store,
@Nonnull String particleSystemId,
@Nonnull Vector3d position) {
try {
ParticleUtil.spawnParticleEffect(particleSystemId, new Vector3d(position), store);
} catch (Throwable ignored) {
// Unknown particle id or API shape mismatch: skip the effect silently.
}
}
/**
* Spawns a particle system with an explicit lifetime ({@code durationSeconds}) so it is
* guaranteed to disappear — used for looping/aura effects (e.g. the whirlwind) that would
* otherwise stay on screen forever.
*/
public static void vfxTimed(
@Nonnull Store<EntityStore> store,
@Nonnull String particleSystemId,
@Nonnull Vector3d position,
float scale,
float durationSeconds) {
try {
ParticleUtil.spawnParticleEffect(
particleSystemId, new Vector3d(position), 0f, 0f, 0f, scale, durationSeconds, store);
} catch (Throwable ignored) {
// Unknown particle id or API shape mismatch: skip the effect silently.
}
}
/**
* Applies a client-synced knockback/impulse to an entity (player or mob) via the engine's
* {@link KnockbackComponent}. This is the only reliable way to move a <em>player</em> from the
* server: directly writing {@code Velocity} is ignored because player movement is
* client-authoritative. Must be called on the world thread.
*/
public static void knockback(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> entity,
@Nonnull Vector3d velocity) {
try {
KnockbackComponent kb = store.ensureAndGetComponent(entity, KnockbackComponent.getComponentType());
kb.setVelocity(new Vector3d(velocity));
kb.setVelocityType(ChangeVelocityType.Set);
kb.setVelocityConfig(defaultVelocityConfig());
kb.setDuration(0f);
kb.setTimer(0f);
} catch (Throwable ignored) {
// Knockback API mismatch: skip silently (ability still deals damage / plays effects).
}
}
@Nonnull
private static VelocityConfig defaultVelocityConfig() {
// Mirrors the engine's default knockback decay preset (see KnockbackApplyCommand).
VelocityConfig config = new VelocityConfig();
config.setAirResistance(0.99f);
config.setAirResistanceMax(0.98f);
config.setGroundResistance(0.94f);
config.setGroundResistanceMax(0.3f);
config.setThreshold(3.0f);
config.setStyle(VelocityThresholdStyle.Linear);
return config;
}
/** Plays a 3D sound event (broadcast to nearby players) at a position. */
public static void sound(
@Nonnull Store<EntityStore> store,
@Nonnull String soundEventId,
@Nonnull Vector3d position) {
sound(store, soundEventId, position, 1.0f, 1.0f);
}
public static void sound(
@Nonnull Store<EntityStore> store,
@Nonnull String soundEventId,
@Nonnull Vector3d position,
float volume,
float pitch) {
try {
int index = SoundEvent.getAssetMap().getIndexOrDefault(soundEventId, -1);
if (index < 0) {
return;
}
SoundUtil.playSoundEvent3d(
index, SoundCategory.SFX, position.x, position.y, position.z, volume, pitch, store);
} catch (Throwable ignored) {
// Unknown sound id or API shape mismatch: skip the sound silently.
}
}
/**
* Plays a weapon animation move on the entity (e.g. {@code WhirlwindChargedSpin}). The move must
* belong to the supplied weapon animation set ({@code Battleaxe} / {@code Longsword}); a null set
* skips the animation.
*/
public static void animation(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> entity,
@Nullable String weaponAnimationSet,
@Nonnull String animationMove) {
if (weaponAnimationSet == null) {
return;
}
try {
AnimationUtils.playAnimation(
entity, AnimationSlot.Action, weaponAnimationSet, animationMove, store);
} catch (Throwable ignored) {
// Animation set/move not loaded for this entity: skip silently.
}
}
}
@@ -0,0 +1,58 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks lightweight combat state per player:
* <ul>
* <li>last time the player took damage (drives the "Résilient" power and out-of-combat detection),</li>
* <li>temporary resistance debuffs applied by abilities (e.g. the Knight war cry).</li>
* </ul>
*/
public final class CombatStateService {
/** A player is considered "in combat" for this long after taking or dealing damage. */
public static final long IN_COMBAT_WINDOW_MILLIS = 6_000L;
private final Map<UUID, Long> lastDamageTakenAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> lastCombatAt = new ConcurrentHashMap<>();
private final Map<UUID, Long> resistanceDebuffUntil = new ConcurrentHashMap<>();
public void markDamageTaken(@Nonnull UUID uuid) {
long now = System.currentTimeMillis();
lastDamageTakenAt.put(uuid, now);
lastCombatAt.put(uuid, now);
}
public void markCombat(@Nonnull UUID uuid) {
lastCombatAt.put(uuid, System.currentTimeMillis());
}
public boolean wasRecentlyHit(@Nonnull UUID uuid, long windowMillis) {
Long at = lastDamageTakenAt.get(uuid);
return at != null && System.currentTimeMillis() - at <= windowMillis;
}
public boolean isInCombat(@Nonnull UUID uuid) {
Long at = lastCombatAt.get(uuid);
return at != null && System.currentTimeMillis() - at <= IN_COMBAT_WINDOW_MILLIS;
}
public void applyResistanceDebuff(@Nonnull UUID uuid, long durationMillis) {
resistanceDebuffUntil.put(uuid, System.currentTimeMillis() + durationMillis);
}
public boolean hasResistanceDebuff(@Nonnull UUID uuid) {
Long until = resistanceDebuffUntil.get(uuid);
return until != null && System.currentTimeMillis() < until;
}
public void clear(@Nonnull UUID uuid) {
lastDamageTakenAt.remove(uuid);
lastCombatAt.remove(uuid);
resistanceDebuffUntil.remove(uuid);
}
}
@@ -0,0 +1,69 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* A small dedicated scheduler for timed gameplay effects (ability ticks, buff reconciliation).
* <p>
* The scheduler runs on its own thread; callbacks that touch ECS state must hop onto the world
* thread via {@link com.hypixel.hytale.server.core.universe.world.World#execute(Runnable)}.
*/
public final class MmorpgScheduler implements Service {
private final HytaleLogger logger;
private ScheduledExecutorService executor;
public MmorpgScheduler(@Nonnull MmorpgPlugin plugin) {
this.logger = plugin.getLogger();
}
@Override
public void start() {
executor = Executors.newScheduledThreadPool(2, runnable -> {
Thread thread = new Thread(runnable, "mmorpg-scheduler");
thread.setDaemon(true);
return thread;
});
}
@Override
public void shutdown() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
public void runLater(@Nonnull Runnable task, long delayMillis) {
if (executor == null) {
return;
}
executor.schedule(wrap(task), Math.max(0L, delayMillis), TimeUnit.MILLISECONDS);
}
@Nonnull
public ScheduledFuture<?> runRepeating(@Nonnull Runnable task, long initialDelayMillis, long periodMillis) {
return executor.scheduleAtFixedRate(
wrap(task), Math.max(0L, initialDelayMillis), Math.max(1L, periodMillis), TimeUnit.MILLISECONDS);
}
@Nonnull
private Runnable wrap(@Nonnull Runnable task) {
return () -> {
try {
task.run();
} catch (Throwable t) {
logger.at(Level.WARNING).log("MMORPG scheduled task failed: %s", t.getMessage());
}
};
}
}
@@ -0,0 +1,92 @@
package com.disklexar.mmorpg.combat;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
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.power.PowerCatalog;
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.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.concurrent.ScheduledFuture;
/**
* Reconciles passive movement effects every couple of seconds.
* <p>
* The "Sprinter" power grants {@code +10%} base movement speed while the player is out of combat;
* the bonus is removed while in combat and restored afterwards.
*/
public final class PassiveEffectService implements Service {
private static final long RECONCILE_PERIOD = 2_000L;
private final MmorpgScheduler scheduler;
private final OnlinePlayerRegistry online;
private final PlayerSessionService sessions;
private final CombatStateService combatState;
private ScheduledFuture<?> task;
public PassiveEffectService(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgScheduler scheduler,
@Nonnull OnlinePlayerRegistry online,
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState) {
this.scheduler = scheduler;
this.online = online;
this.sessions = sessions;
this.combatState = combatState;
}
@Override
public void start() {
task = scheduler.runRepeating(this::reconcileAll, RECONCILE_PERIOD, RECONCILE_PERIOD);
}
@Override
public void shutdown() {
if (task != null) {
task.cancel(false);
task = null;
}
}
private void reconcileAll() {
for (OnlinePlayer player : online.all()) {
PlayerProfile profile = sessions.getSession(player.uuid());
if (profile == null) {
continue;
}
boolean sprinter = profile.getPowers().contains(PowerCatalog.SPRINTER.id());
boolean active = sprinter && !combatState.isInCombat(player.uuid());
player.world().execute(() -> applySpeed(player, active));
}
}
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) {
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());
}
}
}
@@ -0,0 +1,64 @@
package com.disklexar.mmorpg.combat;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageSystems;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import org.joml.Vector3d;
import javax.annotation.Nonnull;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Applies a damage-over-time poison to a target. Each application is independent (so the assassin's
* "Lame Empoisonnée" stacks without limit): calling {@link #applyPoison} again simply schedules
* another series of ticks on top of the existing ones.
*/
public final class PoisonService {
private static final String VFX_POISON = "Impact_Poison";
private final MmorpgScheduler scheduler;
public PoisonService(@Nonnull MmorpgScheduler scheduler) {
this.scheduler = scheduler;
}
/**
* Deals {@code damagePerTick} magic damage to {@code victim} once per {@code periodMillis},
* {@code ticks} times. The caster keeps the kill credit via the damage source.
*/
public void applyPoison(
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> caster,
@Nonnull Ref<EntityStore> victim,
int ticks,
float damagePerTick,
long periodMillis) {
AtomicInteger remaining = new AtomicInteger(ticks);
for (int i = 1; i <= ticks; i++) {
scheduler.runLater(() -> world.execute(() -> {
if (remaining.decrementAndGet() < 0) {
return;
}
if (!victim.isValid()) {
return;
}
// No dedicated "magic" cause exists; ENVIRONMENT keeps the tick from re-triggering
// the on-hit (PHYSICAL) buff logic, so the poison cannot recursively re-poison.
Damage damage = new Damage(new Damage.EntitySource(caster), DamageCause.ENVIRONMENT, damagePerTick);
DamageSystems.executeDamage(victim, store, damage);
TransformComponent transform =
store.getComponent(victim, TransformComponent.getComponentType());
if (transform != null) {
CombatFx.vfx(store, VFX_POISON, new Vector3d(transform.getPosition()));
}
}), (long) i * periodMillis);
}
}
}
@@ -0,0 +1,32 @@
package com.disklexar.mmorpg.combat;
import javax.annotation.Nonnull;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks which entities are currently stunned (used so the Knight war cry can deal bonus damage
* to already-stunned targets). The actual movement lock is enforced by repeatedly zeroing the
* target's velocity for the stun duration.
*/
public final class StunService {
private final Map<UUID, Long> stunnedUntil = new ConcurrentHashMap<>();
public void stun(@Nonnull UUID entityUuid, long durationMillis) {
stunnedUntil.put(entityUuid, System.currentTimeMillis() + durationMillis);
}
public boolean isStunned(@Nonnull UUID entityUuid) {
Long until = stunnedUntil.get(entityUuid);
if (until == null) {
return false;
}
if (System.currentTimeMillis() >= until) {
stunnedUntil.remove(entityUuid);
return false;
}
return true;
}
}
@@ -0,0 +1,66 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.economy.EconomyService;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.component.system.EntityEventSystem;
import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
/**
* Grants money to players with the "Mineur" job each time they break a block.
*/
public final class MinerBlockBreakSystem extends EntityEventSystem<EntityStore, BreakBlockEvent> {
private static final Query<EntityStore> QUERY = Query.any();
private final PlayerSessionService sessions;
private final JobService jobs;
private final EconomyService economy;
public MinerBlockBreakSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull JobService jobs,
@Nonnull EconomyService economy) {
super(BreakBlockEvent.class);
this.sessions = sessions;
this.jobs = jobs;
this.economy = economy;
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull BreakBlockEvent event) {
Ref<EntityStore> breaker = chunk.getReferenceTo(index);
PlayerRef player = store.getComponent(breaker, PlayerRef.getComponentType());
if (player == null) {
return;
}
PlayerProfile profile = sessions.getSession(player.getUuid());
if (profile == null) {
return;
}
if (jobs.has(profile, JobCatalog.MINER.id())) {
economy.deposit(profile, JobCatalog.MINER_REWARD);
}
}
}
@@ -0,0 +1,143 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.combat.CombatBuffService;
import com.disklexar.mmorpg.combat.CombatStateService;
import com.disklexar.mmorpg.combat.PoisonService;
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.power.PowerCatalog;
import com.hypixel.hytale.component.ArchetypeChunk;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.SystemGroup;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageCause;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem;
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.UUID;
/**
* Adjusts incoming damage according to MMORPG state and tracks combat for passive powers:
* <ul>
* <li>victims with an active resistance debuff (Knight war cry) take +20% damage,</li>
* <li>attackers with the "Résilient" power deal +10% damage for 5s after being hit.</li>
* </ul>
*/
public final class MmorpgDamageSystem extends DamageEventSystem {
private static final Query<EntityStore> QUERY = Query.any();
private static final double RESISTANCE_DEBUFF_FACTOR = 1.20;
// Archer "Tir rapide": flat outgoing-damage bonus (server proxy for attack speed).
private static final double RAPID_FIRE_FACTOR = 1.20;
// Archer "Tir puissant": +200% on the next basic attack.
private static final double POWER_SHOT_FACTOR = 3.0;
// Assassin "Lame Empoisonnée": stacking poison applied on each basic hit.
private static final int POISON_TICKS = 5;
private static final float POISON_DAMAGE_PER_TICK = 2.0f;
private static final long POISON_PERIOD_MILLIS = 1_000L;
private final PlayerSessionService sessions;
private final CombatStateService combatState;
private final CombatBuffService combatBuffs;
private final PoisonService poisonService;
private final OnlinePlayerRegistry onlinePlayers;
public MmorpgDamageSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull CombatStateService combatState,
@Nonnull CombatBuffService combatBuffs,
@Nonnull PoisonService poisonService,
@Nonnull OnlinePlayerRegistry onlinePlayers) {
this.sessions = sessions;
this.combatState = combatState;
this.combatBuffs = combatBuffs;
this.poisonService = poisonService;
this.onlinePlayers = onlinePlayers;
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public SystemGroup<EntityStore> getGroup() {
return DamageModule.get().getInspectDamageGroup();
}
@Override
public void handle(
int index,
@Nonnull ArchetypeChunk<EntityStore> chunk,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer,
@Nonnull Damage damage) {
Ref<EntityStore> victim = chunk.getReferenceTo(index);
double amount = damage.getAmount();
UUID victimUuid = uuid(store, victim);
if (victimUuid != null && combatState.hasResistanceDebuff(victimUuid)) {
amount *= RESISTANCE_DEBUFF_FACTOR;
}
if (damage.getSource() instanceof Damage.EntitySource source) {
Ref<EntityStore> attackerRef = source.getRef();
if (attackerRef != null) {
PlayerRef attacker = store.getComponent(attackerRef, PlayerRef.getComponentType());
if (attacker != null) {
UUID attackerUuid = attacker.getUuid();
combatState.markCombat(attackerUuid);
PlayerProfile profile = sessions.getSession(attackerUuid);
if (profile != null
&& profile.getPowers().contains(PowerCatalog.RESILIENT.id())
&& combatState.wasRecentlyHit(attackerUuid, PowerCatalog.RESILIENT_WINDOW_MILLIS)) {
amount *= (1.0 + PowerCatalog.RESILIENT_DAMAGE_BONUS);
}
// Offensive class buffs only apply to direct physical hits, never to the
// damage-over-time / spell ticks our own abilities deal (those are MAGIC), so a
// single sword swing doesn't waste the archer's charged shot, etc.
if (damage.getCause() == DamageCause.PHYSICAL) {
if (combatBuffs.hasRapidFire(attackerUuid)) {
amount *= RAPID_FIRE_FACTOR;
}
if (combatBuffs.consumePowerShot(attackerUuid)) {
amount *= POWER_SHOT_FACTOR;
}
if (combatBuffs.hasPoisonBlade(attackerUuid)) {
OnlinePlayer online = onlinePlayers.get(attackerUuid);
if (online != null) {
poisonService.applyPoison(online.world(), store, attackerRef, victim,
POISON_TICKS, POISON_DAMAGE_PER_TICK, POISON_PERIOD_MILLIS);
}
}
}
}
}
}
if (Math.abs(amount - damage.getAmount()) > 0.0001) {
damage.setAmount((float) amount);
}
PlayerRef victimPlayer = store.getComponent(victim, PlayerRef.getComponentType());
if (victimPlayer != null) {
combatState.markDamageTaken(victimPlayer.getUuid());
}
}
private UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
}
@@ -0,0 +1,105 @@
package com.disklexar.mmorpg.combat.system;
import com.disklexar.mmorpg.economy.EconomyService;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.group.GroupService;
import com.disklexar.mmorpg.rpg.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobService;
import com.hypixel.hytale.component.CommandBuffer;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.component.query.Query;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.util.UUID;
/**
* Rewards a player for killing a mob: kill XP (scaled by race), shared XP for group mates, and
* money when the killer holds the "Tueur de monstre" job.
*/
public final class MmorpgDeathSystem extends DeathSystems.OnDeathSystem {
private static final Query<EntityStore> QUERY = Query.any();
private final PlayerSessionService sessions;
private final ProgressionService progression;
private final GroupService groups;
private final JobService jobs;
private final EconomyService economy;
private final int killExperience;
public MmorpgDeathSystem(
@Nonnull PlayerSessionService sessions,
@Nonnull ProgressionService progression,
@Nonnull GroupService groups,
@Nonnull JobService jobs,
@Nonnull EconomyService economy,
int killExperience) {
this.sessions = sessions;
this.progression = progression;
this.groups = groups;
this.jobs = jobs;
this.economy = economy;
this.killExperience = Math.max(0, killExperience);
}
@Override
public Query<EntityStore> getQuery() {
return QUERY;
}
@Override
public void onComponentAdded(
@Nonnull Ref<EntityStore> deadRef,
@Nonnull DeathComponent death,
@Nonnull Store<EntityStore> store,
@Nonnull CommandBuffer<EntityStore> commandBuffer) {
// Only mobs award rewards; ignore player deaths.
if (store.getComponent(deadRef, PlayerRef.getComponentType()) != null) {
return;
}
Damage info = death.getDeathInfo();
if (info == null || !(info.getSource() instanceof Damage.EntitySource source)) {
return;
}
Ref<EntityStore> killerRef = source.getRef();
if (killerRef == null) {
return;
}
PlayerRef killer = store.getComponent(killerRef, PlayerRef.getComponentType());
if (killer == null) {
return;
}
UUID killerUuid = killer.getUuid();
PlayerProfile profile = sessions.getSession(killerUuid);
if (profile == null) {
return;
}
long gained = progression.grantExperience(profile, killExperience, true);
int shared = groups.shareKillExperience(killerUuid, gained);
long reward = 0L;
if (jobs.has(profile, JobCatalog.MONSTER_SLAYER.id())) {
reward = JobCatalog.MONSTER_SLAYER_REWARD;
economy.deposit(profile, reward);
}
StringBuilder message = new StringBuilder("Monstre vaincu — +" + gained + " XP");
if (reward > 0) {
message.append(" | +").append(reward).append(" money");
}
if (shared > 0) {
message.append(" | XP partagée avec ").append(shared).append(" allié(s)");
}
killer.sendMessage(Message.raw(message.toString()));
}
}
@@ -0,0 +1,43 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.ClassAbilityController;
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.RequiredArg;
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
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;
/**
* {@code /mmorpg ability <1|2|3>} — uses one of your class abilities.
*/
public final class AbilityCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<Integer> slotArg;
public AbilityCommand(@Nonnull MmorpgPlugin plugin) {
super("ability", "Utiliser une capacité de classe (slot 1, 2 ou 3)");
this.plugin = plugin;
this.slotArg = withRequiredArg("slot", "Numéro de la capacité (1-3)", ArgTypes.INTEGER);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
ClassAbilityController controller = plugin.getClassAbilityController();
int slot = context.get(slotArg);
if (!controller.shouldSuppressVanilla(playerRef, slot)) {
context.sendMessage(Message.raw("Vous n'avez pas de classe. Utilisez /mmorpg class choisir <classe>."));
return;
}
controller.tryCast(playerRef, world, slot, null);
}
}
@@ -0,0 +1,159 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.OnlinePlayer;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.ui.AbilityBarService;
import com.disklexar.mmorpg.rpg.clazz.ClassCatalog;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
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.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;
/**
* {@code /mmorpg class <choisir|quitter|info>}
*/
public final class ClassCommand extends AbstractCommandCollection {
public ClassCommand(@Nonnull MmorpgPlugin plugin) {
super("class", "Gérer votre classe");
addSubCommand(new Choose(plugin));
addSubCommand(new Leave(plugin));
addSubCommand(new Info(plugin));
}
private static final class Choose extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<String> classArg;
Choose(@Nonnull MmorpgPlugin plugin) {
super("choisir", "Choisir une classe");
this.plugin = plugin;
this.classArg = withRequiredArg("classe", "Identifiant de la classe", ArgTypes.STRING);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
ClassService classService = CommandSupport.service(plugin, ClassService.class);
if (profile == null || classService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
String id = context.get(classArg);
if (classService.choose(profile, id)) {
PlayerClass chosen = classService.getClass(profile);
context.sendMessage(Message.raw("Classe choisie : " + (chosen != null ? chosen.displayName() : id)));
syncAbilityBar(plugin, playerRef, profile);
} else {
context.sendMessage(Message.raw("Classe inconnue. Disponibles : " + classList()));
}
}
}
private static final class Leave extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Leave(@Nonnull MmorpgPlugin plugin) {
super("quitter", "Quitter votre classe");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
ClassService classService = CommandSupport.service(plugin, ClassService.class);
if (profile == null || classService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
if (classService.leave(profile)) {
context.sendMessage(Message.raw("Vous avez quitté votre classe."));
dismissAbilityBar(plugin, playerRef);
} else {
context.sendMessage(Message.raw("Vous n'avez aucune classe."));
}
}
}
private static final class Info extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Info(@Nonnull MmorpgPlugin plugin) {
super("info", "Informations sur les classes");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
ClassService classService = CommandSupport.service(plugin, ClassService.class);
StringBuilder builder = new StringBuilder("=== Classes ===");
if (profile != null && classService != null) {
PlayerClass current = classService.getClass(profile);
builder.append("\nClasse actuelle : ").append(current != null ? current.displayName() : "aucune");
}
for (PlayerClass playerClass : ClassCatalog.all()) {
builder.append("\n- ").append(playerClass.displayName())
.append(" (").append(playerClass.id()).append(") — ")
.append(playerClass.description())
.append("\n Armes : ").append(String.join(", ", playerClass.weapons()));
for (AbilityDefinition ability : playerClass.abilities()) {
builder.append("\n • Capacité ").append(ability.slot()).append("")
.append(ability.displayName()).append(" : ").append(ability.description());
}
}
context.sendMessage(Message.raw(builder.toString()));
}
}
private static void syncAbilityBar(
@Nonnull MmorpgPlugin plugin,
@Nonnull PlayerRef playerRef,
@Nonnull PlayerProfile profile) {
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class);
if (abilityBar == null || onlinePlayers == null) {
return;
}
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
if (online != null) {
abilityBar.sync(online, profile);
}
}
private static void dismissAbilityBar(@Nonnull MmorpgPlugin plugin, @Nonnull PlayerRef playerRef) {
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
if (abilityBar != null) {
abilityBar.dismiss(playerRef.getUuid());
}
}
@Nonnull
private static String classList() {
StringBuilder builder = new StringBuilder();
for (PlayerClass playerClass : ClassCatalog.all()) {
if (builder.length() > 0) {
builder.append(", ");
}
builder.append(playerClass.id());
}
return builder.toString();
}
}
@@ -0,0 +1,51 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.UUID;
/**
* Shared helpers for MMORPG commands: service lookup and current-player profile resolution.
*/
final class CommandSupport {
private CommandSupport() {
}
@Nullable
static <T> T service(@Nonnull MmorpgPlugin plugin, @Nonnull Class<T> type) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return null;
}
return bootstrap.getRegistry().get(type);
}
@Nullable
static UUID uuid(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
return component == null ? null : component.getUuid();
}
@Nullable
static PlayerProfile profile(
@Nonnull MmorpgPlugin plugin,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref) {
UUID uuid = uuid(store, ref);
if (uuid == null) {
return null;
}
PlayerSessionService sessions = service(plugin, PlayerSessionService.class);
return sessions == null ? null : sessions.getSession(uuid);
}
}
@@ -0,0 +1,180 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.rpg.group.Group;
import com.disklexar.mmorpg.rpg.group.GroupService;
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.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.util.UUID;
/**
* {@code /mmorpg group <inviter|accepter|quitter|kick|info>}
*/
public final class GroupCommand extends AbstractCommandCollection {
public GroupCommand(@Nonnull MmorpgPlugin plugin) {
super("group", "Gérer votre groupe (partage d'XP)");
addSubCommand(new Invite(plugin));
addSubCommand(new Accept(plugin));
addSubCommand(new Leave(plugin));
addSubCommand(new Kick(plugin));
addSubCommand(new Info(plugin));
}
private static final class Invite extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<PlayerRef> targetArg;
Invite(@Nonnull MmorpgPlugin plugin) {
super("inviter", "Inviter un joueur dans votre groupe");
this.plugin = plugin;
this.targetArg = withRequiredArg("joueur", "Joueur à inviter", ArgTypes.PLAYER_REF);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
GroupService groups = CommandSupport.service(plugin, GroupService.class);
UUID inviter = CommandSupport.uuid(store, ref);
if (groups == null || inviter == null) {
context.sendMessage(Message.raw("Service de groupe indisponible."));
return;
}
PlayerRef target = context.get(targetArg);
switch (groups.invite(inviter, target.getUuid())) {
case INVITED -> {
context.sendMessage(Message.raw("Invitation envoyée à " + target.getUsername() + "."));
target.sendMessage(Message.raw(playerRef.getUsername()
+ " vous invite dans son groupe. Tapez /mmorpg group accepter"));
}
case SELF -> context.sendMessage(Message.raw("Vous ne pouvez pas vous inviter vous-même."));
case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut inviter."));
case TARGET_ALREADY_GROUPED ->
context.sendMessage(Message.raw("Ce joueur est déjà dans un groupe."));
}
}
}
private static final class Accept extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Accept(@Nonnull MmorpgPlugin plugin) {
super("accepter", "Accepter une invitation de groupe");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
GroupService groups = CommandSupport.service(plugin, GroupService.class);
UUID uuid = CommandSupport.uuid(store, ref);
if (groups == null || uuid == null) {
context.sendMessage(Message.raw("Service de groupe indisponible."));
return;
}
switch (groups.accept(uuid)) {
case JOINED -> context.sendMessage(Message.raw("Vous avez rejoint le groupe."));
case NO_INVITE -> context.sendMessage(Message.raw("Aucune invitation en attente."));
case ALREADY_GROUPED -> context.sendMessage(Message.raw("Vous êtes déjà dans un groupe."));
}
}
}
private static final class Leave extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Leave(@Nonnull MmorpgPlugin plugin) {
super("quitter", "Quitter votre groupe");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
GroupService groups = CommandSupport.service(plugin, GroupService.class);
UUID uuid = CommandSupport.uuid(store, ref);
if (groups == null || uuid == null) {
context.sendMessage(Message.raw("Service de groupe indisponible."));
return;
}
context.sendMessage(Message.raw(groups.leave(uuid)
? "Vous avez quitté votre groupe."
: "Vous n'êtes dans aucun groupe."));
}
}
private static final class Kick extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<PlayerRef> targetArg;
Kick(@Nonnull MmorpgPlugin plugin) {
super("kick", "Exclure un joueur (créateur uniquement)");
this.plugin = plugin;
this.targetArg = withRequiredArg("joueur", "Joueur à exclure", ArgTypes.PLAYER_REF);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
GroupService groups = CommandSupport.service(plugin, GroupService.class);
UUID owner = CommandSupport.uuid(store, ref);
if (groups == null || owner == null) {
context.sendMessage(Message.raw("Service de groupe indisponible."));
return;
}
PlayerRef target = context.get(targetArg);
switch (groups.kick(owner, target.getUuid())) {
case KICKED -> {
context.sendMessage(Message.raw(target.getUsername() + " a été exclu du groupe."));
target.sendMessage(Message.raw("Vous avez été exclu du groupe."));
}
case NOT_OWNER -> context.sendMessage(Message.raw("Seul le créateur peut exclure."));
case NOT_MEMBER -> context.sendMessage(Message.raw("Ce joueur n'est pas dans votre groupe."));
case CANNOT_KICK_SELF ->
context.sendMessage(Message.raw("Utilisez /mmorpg group quitter pour partir."));
}
}
}
private static final class Info extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Info(@Nonnull MmorpgPlugin plugin) {
super("info", "Informations sur votre groupe");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
GroupService groups = CommandSupport.service(plugin, GroupService.class);
UUID uuid = CommandSupport.uuid(store, ref);
if (groups == null || uuid == null) {
context.sendMessage(Message.raw("Service de groupe indisponible."));
return;
}
Group group = groups.getGroupOf(uuid);
if (group == null) {
context.sendMessage(Message.raw("Vous n'êtes dans aucun groupe. "
+ "Le partage d'XP donne " + GroupService.SHARED_XP_FRACTION_PERCENT
+ "% de l'XP d'un kill aux autres membres."));
return;
}
context.sendMessage(Message.raw("=== Groupe ===\nCréateur : "
+ (group.isOwner(uuid) ? "vous" : group.getOwner())
+ "\nMembres : " + group.getMembers().size()
+ "\nPartage d'XP : " + GroupService.SHARED_XP_FRACTION_PERCENT + "%"));
}
}
}
@@ -0,0 +1,64 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.OnlinePlayer;
import com.disklexar.mmorpg.player.OnlinePlayerRegistry;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
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.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;
/**
* {@code /mmorpg hud} — force-affiche la barre de capacités (debug / test manuel).
*/
public final class HudCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public HudCommand(@Nonnull MmorpgPlugin plugin) {
super("hud", "Afficher la barre de capacités MMORPG");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
if (profile == null) {
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
return;
}
ClassService classService = CommandSupport.service(plugin, ClassService.class);
AbilityBarService abilityBar = CommandSupport.service(plugin, AbilityBarService.class);
OnlinePlayerRegistry onlinePlayers = CommandSupport.service(plugin, OnlinePlayerRegistry.class);
if (classService == null || abilityBar == null || onlinePlayers == null) {
context.sendMessage(Message.raw("Service HUD indisponible."));
return;
}
if (!classService.hasClass(profile)) {
context.sendMessage(Message.raw("Choisissez d'abord une classe : /mmorpg class choisir <id>"));
return;
}
OnlinePlayer online = onlinePlayers.get(playerRef.getUuid());
if (online == null) {
context.sendMessage(Message.raw("Joueur non enregistré. Reconnectez-vous."));
return;
}
abilityBar.dismiss(online);
abilityBar.sync(online, profile);
context.sendMessage(Message.raw("Barre de capacités renvoyée (colonne gauche)."));
}
}
@@ -0,0 +1,39 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.PlayerInfoView;
import com.disklexar.mmorpg.player.model.PlayerProfile;
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.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;
/**
* {@code /mmorpg info} — prints the full player profile in chat (reliable fallback for the UI).
*/
public final class InfoCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public InfoCommand(@Nonnull MmorpgPlugin plugin) {
super("info", "Afficher toutes vos informations de joueur");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
if (profile == null) {
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
return;
}
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
}
}
@@ -0,0 +1,131 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobDefinition;
import com.disklexar.mmorpg.rpg.job.JobService;
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.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;
/**
* {@code /mmorpg job <rejoindre|quitter|info>}
*/
public final class JobCommand extends AbstractCommandCollection {
public JobCommand(@Nonnull MmorpgPlugin plugin) {
super("job", "Gérer vos métiers");
addSubCommand(new Join(plugin));
addSubCommand(new Leave(plugin));
addSubCommand(new Info(plugin));
}
private static final class Join extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<String> jobArg;
Join(@Nonnull MmorpgPlugin plugin) {
super("rejoindre", "Rejoindre un métier");
this.plugin = plugin;
this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
JobService jobService = CommandSupport.service(plugin, JobService.class);
if (profile == null || jobService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
String id = context.get(jobArg);
JobDefinition job = JobCatalog.byId(id);
if (job == null) {
context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList()));
return;
}
context.sendMessage(Message.raw(jobService.join(profile, id)
? "Métier rejoint : " + job.displayName()
: "Vous exercez déjà " + job.displayName() + "."));
}
}
private static final class Leave extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<String> jobArg;
Leave(@Nonnull MmorpgPlugin plugin) {
super("quitter", "Quitter un métier");
this.plugin = plugin;
this.jobArg = withRequiredArg("metier", "Identifiant du métier", ArgTypes.STRING);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
JobService jobService = CommandSupport.service(plugin, JobService.class);
if (profile == null || jobService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
String id = context.get(jobArg);
JobDefinition job = JobCatalog.byId(id);
if (job == null) {
context.sendMessage(Message.raw("Métier inconnu. Disponibles : " + jobList()));
return;
}
context.sendMessage(Message.raw(jobService.leave(profile, id)
? "Métier quitté : " + job.displayName()
: "Vous n'exercez pas " + job.displayName() + "."));
}
}
private static final class Info extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Info(@Nonnull MmorpgPlugin plugin) {
super("info", "Informations sur les métiers");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
StringBuilder builder = new StringBuilder("=== Métiers ===");
for (JobDefinition job : JobCatalog.all()) {
boolean active = profile != null && profile.getJobs().contains(job.id());
builder.append("\n- ").append(job.displayName())
.append(" (").append(job.id()).append(") ")
.append(active ? "[actif]" : "")
.append("\n ").append(job.description());
}
context.sendMessage(Message.raw(builder.toString()));
}
}
@Nonnull
private static String jobList() {
StringBuilder builder = new StringBuilder();
for (JobDefinition job : JobCatalog.all()) {
if (builder.length() > 0) {
builder.append(", ");
}
builder.append(job.id());
}
return builder.toString();
}
}
@@ -0,0 +1,55 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.PlayerInfoView;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.ui.PlayerInfoPage;
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.basecommands.AbstractPlayerCommand;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager;
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;
/**
* {@code /mmorpg menu} — opens the custom UI page listing all player info.
* Falls back to the chat panel if the UI page cannot be opened.
*/
public final class MenuCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public MenuCommand(@Nonnull MmorpgPlugin plugin) {
super("menu", "Ouvrir l'interface d'informations du joueur");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
if (profile == null) {
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
return;
}
Player player = store.getComponent(ref, Player.getComponentType());
if (player == null) {
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
return;
}
PageManager pageManager = player.getPageManager();
world.execute(() -> {
try {
pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, false));
} catch (Throwable t) {
playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
}
});
}
}
@@ -0,0 +1,55 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.PlayerInfoView;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.ui.PlayerInfoPage;
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.basecommands.AbstractPlayerCommand;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.entity.entities.player.pages.PageManager;
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;
/**
* {@code /mmorpg menucustom} — opens the fully custom player-info window backed by this plugin's
* Opens the designed asset-pack page ({@code Pages/MmorpgPlayerInfo.ui}).
*/
public final class MenuCustomCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public MenuCustomCommand(@Nonnull MmorpgPlugin plugin) {
super("menucustom", "Ouvrir l'interface joueur personnalisée (asset pack)");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
if (profile == null) {
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous."));
return;
}
Player player = store.getComponent(ref, Player.getComponentType());
if (player == null) {
context.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
return;
}
PageManager pageManager = player.getPageManager();
world.execute(() -> {
try {
pageManager.openCustomPage(ref, store, new PlayerInfoPage(playerRef, profile, true));
} catch (Throwable t) {
playerRef.sendMessage(Message.raw(PlayerInfoView.asText(profile)));
}
});
}
}
@@ -0,0 +1,26 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
import javax.annotation.Nonnull;
/**
* Root command: /mmorpg
*/
public final class MmorpgCommand extends AbstractCommandCollection {
public MmorpgCommand(@Nonnull MmorpgPlugin plugin) {
super("mmorpg", "Commandes du serveur MMORPG");
addSubCommand(new MmorpgHelpCommand());
addSubCommand(new MmorpgProfileCommand(plugin));
addSubCommand(new InfoCommand(plugin));
addSubCommand(new MenuCommand(plugin));
addSubCommand(new HudCommand(plugin));
addSubCommand(new ClassCommand(plugin));
addSubCommand(new PowerCommand(plugin));
addSubCommand(new JobCommand(plugin));
addSubCommand(new GroupCommand(plugin));
addSubCommand(new AbilityCommand(plugin));
}
}
@@ -0,0 +1,40 @@
package com.disklexar.mmorpg.command;
import com.hypixel.hytale.server.core.Message;
import com.hypixel.hytale.server.core.command.system.CommandContext;
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractAsyncCommand;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.concurrent.CompletableFuture;
/**
* /mmorpg help
*/
public final class MmorpgHelpCommand extends AbstractAsyncCommand {
public MmorpgHelpCommand() {
super("help", "Affiche l'aide MMORPG");
}
@Nullable
@Override
protected CompletableFuture<Void> executeAsync(@Nonnull CommandContext context) {
context.sendMessage(Message.raw("""
=== MMORPG ===
/mmorpg help — cette aide
/mmorpg profile — résumé court de votre profil
/mmorpg info — toutes vos informations (chat)
/mmorpg menu — interface des informations du joueur
/mmorpg menucustom — interface personnalisée (asset pack, test)
/mmorpg class <choisir|quitter|info> [classe]
/mmorpg power <recevoir|retirer|info> [pouvoir]
/mmorpg job <rejoindre|quitter|info> [metier]
/mmorpg group <inviter|accepter|quitter|kick|info> [joueur]
/mmorpg ability <1|2|3> — utiliser une capacité de classe
(ou utilisez directement les touches « Use ability 1/2/3 » en jeu)
Classes : Chevalier, Archer, Mage, Assassin (voir /mmorpg class info)
"""));
return CompletableFuture.completedFuture(null);
}
}
@@ -0,0 +1,65 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.bootstrap.Bootstrap;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
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.basecommands.AbstractPlayerCommand;
import com.hypixel.hytale.server.core.entity.UUIDComponent;
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;
/**
* /mmorpg profile
*/
public final class MmorpgProfileCommand extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
public MmorpgProfileCommand(@Nonnull MmorpgPlugin plugin) {
super("profile", "Affiche votre profil MMORPG");
this.plugin = plugin;
}
@Override
protected void execute(
@Nonnull CommandContext context,
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull PlayerRef playerRef,
@Nonnull World world) {
UUIDComponent uuidComponent = store.getComponent(ref, UUIDComponent.getComponentType());
if (uuidComponent == null) {
context.sendMessage(Message.raw("Impossible de lire votre profil."));
return;
}
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
context.sendMessage(Message.raw("Le plugin MMORPG n'est pas encore prêt."));
return;
}
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
PlayerProfile profile = sessions.getSession(uuidComponent.getUuid());
if (profile == null) {
context.sendMessage(Message.raw("Aucun profil chargé. Reconnectez-vous au serveur."));
return;
}
context.sendMessage(Message.raw(String.format(
"Profil MMORPG — %s | Niveau %d | XP %d",
profile.getDisplayName(),
profile.getLevel(),
profile.getExperience())));
}
}
@@ -0,0 +1,131 @@
package com.disklexar.mmorpg.command;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
import com.disklexar.mmorpg.rpg.power.PowerDefinition;
import com.disklexar.mmorpg.rpg.power.PowerService;
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.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;
/**
* {@code /mmorpg power <recevoir|retirer|info>}
*/
public final class PowerCommand extends AbstractCommandCollection {
public PowerCommand(@Nonnull MmorpgPlugin plugin) {
super("power", "Gérer vos pouvoirs passifs");
addSubCommand(new Grant(plugin));
addSubCommand(new Revoke(plugin));
addSubCommand(new Info(plugin));
}
private static final class Grant extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<String> powerArg;
Grant(@Nonnull MmorpgPlugin plugin) {
super("recevoir", "Recevoir un pouvoir");
this.plugin = plugin;
this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
PowerService powerService = CommandSupport.service(plugin, PowerService.class);
if (profile == null || powerService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
String id = context.get(powerArg);
PowerDefinition power = PowerCatalog.byId(id);
if (power == null) {
context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList()));
return;
}
context.sendMessage(Message.raw(powerService.grant(profile, id)
? "Pouvoir reçu : " + power.displayName()
: "Vous possédez déjà " + power.displayName() + "."));
}
}
private static final class Revoke extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
private final RequiredArg<String> powerArg;
Revoke(@Nonnull MmorpgPlugin plugin) {
super("retirer", "Retirer un pouvoir");
this.plugin = plugin;
this.powerArg = withRequiredArg("pouvoir", "Identifiant du pouvoir", ArgTypes.STRING);
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
PowerService powerService = CommandSupport.service(plugin, PowerService.class);
if (profile == null || powerService == null) {
context.sendMessage(Message.raw("Profil indisponible."));
return;
}
String id = context.get(powerArg);
PowerDefinition power = PowerCatalog.byId(id);
if (power == null) {
context.sendMessage(Message.raw("Pouvoir inconnu. Disponibles : " + powerList()));
return;
}
context.sendMessage(Message.raw(powerService.remove(profile, id)
? "Pouvoir retiré : " + power.displayName()
: "Vous ne possédez pas " + power.displayName() + "."));
}
}
private static final class Info extends AbstractPlayerCommand {
private final MmorpgPlugin plugin;
Info(@Nonnull MmorpgPlugin plugin) {
super("info", "Informations sur les pouvoirs");
this.plugin = plugin;
}
@Override
protected void execute(@Nonnull CommandContext context, @Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref, @Nonnull PlayerRef playerRef, @Nonnull World world) {
PlayerProfile profile = CommandSupport.profile(plugin, store, ref);
StringBuilder builder = new StringBuilder("=== Pouvoirs (passifs, aucun par défaut) ===");
for (PowerDefinition power : PowerCatalog.all()) {
boolean owned = profile != null && profile.getPowers().contains(power.id());
builder.append("\n- ").append(power.displayName())
.append(" (").append(power.id()).append(") ")
.append(owned ? "[possédé]" : "")
.append("\n ").append(power.description());
}
context.sendMessage(Message.raw(builder.toString()));
}
}
@Nonnull
private static String powerList() {
StringBuilder builder = new StringBuilder();
for (PowerDefinition power : PowerCatalog.all()) {
if (builder.length() > 0) {
builder.append(", ");
}
builder.append(power.id());
}
return builder.toString();
}
}
@@ -0,0 +1,112 @@
package com.disklexar.mmorpg.core.config;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Plugin configuration loaded from {@code config.json} in the plugin data directory.
*/
public final class MmorpgConfig {
private static final Gson GSON = new Gson();
private boolean debug;
@SerializedName("DefaultLevel")
private int defaultLevel = 1;
@SerializedName("MaxPlayers")
private int maxPlayers = 500;
@SerializedName("BaseXpPerLevel")
private int baseXpPerLevel = 100;
@SerializedName("KillExperience")
private int killExperience = 10;
private DatabaseConfig database = new DatabaseConfig();
private FeaturesConfig features = new FeaturesConfig();
public static MmorpgConfig load(Path dataDirectory) throws IOException {
Files.createDirectories(dataDirectory);
Path configFile = dataDirectory.resolve("config.json");
if (!Files.exists(configFile)) {
try (InputStream defaults = MmorpgConfig.class.getResourceAsStream("/config.json")) {
if (defaults == null) {
throw new IOException("Default config.json not found in plugin resources");
}
Files.copy(defaults, configFile);
}
}
try (InputStreamReader reader = new InputStreamReader(
Files.newInputStream(configFile), StandardCharsets.UTF_8)) {
MmorpgConfig config = GSON.fromJson(reader, MmorpgConfig.class);
if (config.database == null) {
config.database = new DatabaseConfig();
}
if (config.features == null) {
config.features = new FeaturesConfig();
}
return config;
}
}
public boolean isDebug() {
return debug;
}
public int getDefaultLevel() {
return defaultLevel;
}
public int getMaxPlayers() {
return maxPlayers;
}
public int getBaseXpPerLevel() {
return baseXpPerLevel;
}
public int getKillExperience() {
return killExperience;
}
public DatabaseConfig getDatabase() {
return database;
}
public FeaturesConfig getFeatures() {
return features;
}
public static final class DatabaseConfig {
@SerializedName("FileName")
private String fileName = "mmorpg.db";
public String getFileName() {
return fileName;
}
}
public static final class FeaturesConfig {
private boolean economy;
private boolean quests;
private boolean guilds;
public boolean isEconomyEnabled() {
return economy;
}
public boolean isQuestsEnabled() {
return quests;
}
public boolean isGuildsEnabled() {
return guilds;
}
}
}
@@ -0,0 +1,11 @@
package com.disklexar.mmorpg.core.service;
/**
* Lifecycle contract for MMORPG services.
*/
public interface Service {
void start();
void shutdown();
}
@@ -0,0 +1,35 @@
package com.disklexar.mmorpg.economy;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import javax.annotation.Nonnull;
/**
* Minimal currency operations on a {@link PlayerProfile}. Money is persisted with the profile.
*/
public final class EconomyService {
public long getBalance(@Nonnull PlayerProfile profile) {
return profile.getMoney();
}
public void deposit(@Nonnull PlayerProfile profile, long amount) {
if (amount <= 0) {
return;
}
profile.setMoney(profile.getMoney() + amount);
profile.touch();
}
public boolean withdraw(@Nonnull PlayerProfile profile, long amount) {
if (amount <= 0) {
return true;
}
if (profile.getMoney() < amount) {
return false;
}
profile.setMoney(profile.getMoney() - amount);
profile.touch();
return true;
}
}
@@ -0,0 +1,7 @@
/**
* Economy system (currency, shops, auctions).
* <p>
* Disabled by default via {@code Features.Economy} in config.json.
* Planned: wallets, transactions, NPC vendors.
*/
package com.disklexar.mmorpg.economy;
@@ -0,0 +1,88 @@
package com.disklexar.mmorpg.events;
import com.disklexar.mmorpg.combat.ClassAbilityController;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.ForkedChainId;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.protocol.Packet;
import com.hypixel.hytale.protocol.packets.interaction.CancelInteractionChain;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChain;
import com.hypixel.hytale.protocol.packets.interaction.SyncInteractionChains;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.io.adapter.PlayerPacketFilter;
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.util.ArrayList;
import java.util.List;
/**
* Intercepts {@link SyncInteractionChains} before the vanilla interaction pipeline runs.
* When a player with an MMORPG class presses Ability 1/2/3, the vanilla weapon ability is
* cancelled client-side and the plugin casts the class ability instead.
*/
public final class AbilityPacketFilter implements PlayerPacketFilter {
private final ClassAbilityController controller;
public AbilityPacketFilter(@Nonnull ClassAbilityController controller) {
this.controller = controller;
}
@Override
public boolean test(@Nonnull PlayerRef playerRef, @Nonnull Packet packet) {
if (!(packet instanceof SyncInteractionChains syncPacket)
|| syncPacket.updates == null
|| syncPacket.updates.length == 0) {
return false;
}
List<SyncInteractionChain> keep = new ArrayList<>(syncPacket.updates.length);
boolean blockedAny = false;
for (SyncInteractionChain chain : syncPacket.updates) {
int slot = ClassAbilityController.slotFor(chain.interactionType);
if (slot == 0 || !chain.initial || !controller.shouldSuppressVanilla(playerRef, slot)) {
keep.add(chain);
continue;
}
blockedAny = true;
cancelAndCast(playerRef, chain, slot);
}
if (!blockedAny) {
return false;
}
if (keep.isEmpty()) {
return true;
}
syncPacket.updates = keep.toArray(new SyncInteractionChain[0]);
return false;
}
private void cancelAndCast(
@Nonnull PlayerRef playerRef,
@Nonnull SyncInteractionChain chain,
int slot) {
ForkedChainId forkedId = chain.forkedId;
playerRef.getPacketHandler().writeNoCache(new CancelInteractionChain(chain.chainId, forkedId));
Ref<EntityStore> entityRef = playerRef.getReference();
if (entityRef == null || !entityRef.isValid()) {
return;
}
Store<EntityStore> store = entityRef.getStore();
Player player = store.getComponent(entityRef, Player.getComponentType());
if (player == null) {
return;
}
World world = player.getWorld();
world.execute(() -> controller.tryCast(playerRef, world, slot, null));
}
}
@@ -0,0 +1,133 @@
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.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.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.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import java.sql.SQLException;
import java.util.UUID;
import java.util.logging.Level;
/**
* Handles player join and leave for MMORPG session management.
*/
public final class PlayerConnectionHandler {
private final MmorpgPlugin plugin;
private final HytaleLogger logger;
public PlayerConnectionHandler(@Nonnull MmorpgPlugin plugin) {
this.plugin = plugin;
this.logger = plugin.getLogger();
}
public void onPlayerReady(@Nonnull PlayerReadyEvent event) {
Player player = event.getPlayer();
Ref<EntityStore> entityRef = event.getPlayerRef();
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return;
}
Store<EntityStore> store = player.getWorld().getEntityStore().getStore();
PlayerRef universePlayerRef = store.getComponent(entityRef, PlayerRef.getComponentType());
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
try {
UUID uuid = resolveUuid(store, entityRef, player);
String displayName = resolveDisplayName(store, entityRef, player);
PlayerProfile profile = sessions.loadOrCreate(uuid, displayName);
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 class info"));
if (classService.hasClass(profile)) {
scheduler.runLater(() -> abilityBar.sync(uuid, profile), 5_000L);
}
}
} catch (SQLException e) {
logger.at(Level.SEVERE).log(
"Failed to load profile for %s: %s",
resolveDisplayName(store, entityRef, player),
e.getMessage());
if (universePlayerRef != null) {
universePlayerRef.sendMessage(Message.raw(
"Erreur lors du chargement de votre profil MMORPG."));
}
}
}
public void onPlayerDisconnect(@Nonnull PlayerDisconnectEvent event) {
Bootstrap bootstrap = plugin.getBootstrap();
if (bootstrap == null) {
return;
}
PlayerRef playerRef = event.getPlayerRef();
OnlinePlayerRegistry onlinePlayers = bootstrap.getRegistry().require(OnlinePlayerRegistry.class);
onlinePlayers.remove(playerRef.getUuid());
bootstrap.getRegistry().require(AbilityBarService.class).dismiss(playerRef.getUuid());
PlayerSessionService sessions = bootstrap.getRegistry().require(PlayerSessionService.class);
sessions.unload(playerRef.getUuid());
}
@Nonnull
private UUID resolveUuid(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull Player player) {
UUIDComponent component = store.getComponent(ref, UUIDComponent.getComponentType());
if (component != null) {
return component.getUuid();
}
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
return playerRef.getUuid();
}
throw new IllegalStateException("UUID not found for player: " + resolveDisplayName(store, ref, player));
}
@Nonnull
private String resolveDisplayName(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> ref,
@Nonnull Player player) {
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
if (playerRef != null) {
return playerRef.getUsername();
}
return "Joueur";
}
}
@@ -0,0 +1,90 @@
package com.disklexar.mmorpg.interaction;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.ClassAbilityController;
import com.hypixel.hytale.codec.Codec;
import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.InteractionState;
import com.hypixel.hytale.protocol.InteractionType;
import com.hypixel.hytale.server.core.entity.InteractionContext;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
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;
/**
* Native interaction ({@code mmorpg_cast_ability}) wired from item asset overrides.
* Client and server stay in sync when weapon JSON maps Ability1/2/3 to this type.
*/
public final class MmorpgCastAbilityInteraction extends SimpleInstantInteraction {
public static final String ID = "mmorpg_cast_ability";
public static final BuilderCodec<MmorpgCastAbilityInteraction> CODEC = BuilderCodec.builder(
MmorpgCastAbilityInteraction.class,
MmorpgCastAbilityInteraction::new,
SimpleInstantInteraction.CODEC)
.append(new KeyedCodec<>("Slot", Codec.INTEGER),
(interaction, slot) -> interaction.slot = slot,
interaction -> interaction.slot)
.add()
.build();
private int slot = 1;
public MmorpgCastAbilityInteraction() {
}
public MmorpgCastAbilityInteraction(@Nonnull String id) {
super(id);
}
@Override
protected void firstRun(
@Nonnull InteractionType interactionType,
@Nonnull InteractionContext interactionContext,
@Nonnull CooldownHandler cooldownHandler) {
int abilitySlot = slot;
if (abilitySlot == 0) {
abilitySlot = ClassAbilityController.slotFor(interactionType);
}
if (abilitySlot == 0) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
Ref<EntityStore> entityRef = interactionContext.getEntity();
if (entityRef == null || !entityRef.isValid()) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
Store<EntityStore> store = entityRef.getStore();
PlayerRef playerRef = store.getComponent(entityRef, PlayerRef.getComponentType());
Player player = store.getComponent(entityRef, Player.getComponentType());
if (playerRef == null || player == null) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
MmorpgPlugin plugin = MmorpgPlugin.get();
if (plugin == null) {
interactionContext.getState().state = InteractionState.Failed;
return;
}
World world = player.getWorld();
Ref<EntityStore> target = interactionContext.getTargetEntity();
ClassAbilityController controller = plugin.getClassAbilityController();
if (!controller.tryCast(playerRef, world, abilitySlot, target)) {
interactionContext.getState().state = InteractionState.Failed;
}
}
}
@@ -0,0 +1,72 @@
package com.disklexar.mmorpg.persistence;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.config.MmorpgConfig;
import com.disklexar.mmorpg.core.service.Service;
import com.disklexar.mmorpg.persistence.schema.SchemaInitializer;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.logging.Level;
/**
* Manages the SQLite database connection for the MMORPG plugin.
*/
public final class DatabaseManager implements Service {
private final MmorpgPlugin plugin;
private final MmorpgConfig config;
private final HytaleLogger logger;
private Connection connection;
public DatabaseManager(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgConfig config) {
this.plugin = plugin;
this.config = config;
this.logger = plugin.getLogger();
}
@Override
public void start() {
try {
Path dbPath = plugin.getDataDirectory().resolve(config.getDatabase().getFileName());
String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath();
connection = DriverManager.getConnection(jdbcUrl);
connection.setAutoCommit(true);
SchemaInitializer.apply(connection);
logger.at(Level.INFO).log("SQLite database ready at %s", dbPath);
} catch (SQLException e) {
throw new IllegalStateException("Failed to initialize SQLite database", e);
}
}
@Override
public void shutdown() {
if (connection != null) {
try {
connection.close();
logger.at(Level.INFO).log("SQLite connection closed");
} catch (SQLException e) {
logger.at(Level.WARNING).log("Error closing SQLite connection: %s", e.getMessage());
} finally {
connection = null;
}
}
}
@Nonnull
public Connection getConnection() {
if (connection == null) {
throw new IllegalStateException("Database is not initialized");
}
return connection;
}
}
@@ -0,0 +1,100 @@
package com.disklexar.mmorpg.persistence.repository;
import com.disklexar.mmorpg.persistence.DatabaseManager;
import com.disklexar.mmorpg.rpg.group.Group;
import javax.annotation.Nonnull;
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 {@link Group} parties and their members.
*/
public final class GroupRepository {
private final DatabaseManager databaseManager;
public GroupRepository(@Nonnull DatabaseManager databaseManager) {
this.databaseManager = databaseManager;
}
@Nonnull
public List<Group> loadAll() throws SQLException {
List<Group> groups = new ArrayList<>();
try (PreparedStatement statement = connection().prepareStatement(
"SELECT id, owner_uuid, created_at FROM groups");
ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
Group group = new Group(
UUID.fromString(resultSet.getString("id")),
UUID.fromString(resultSet.getString("owner_uuid")),
resultSet.getLong("created_at"));
groups.add(group);
}
}
for (Group group : groups) {
try (PreparedStatement statement = connection().prepareStatement(
"SELECT player_uuid FROM group_members WHERE group_id = ? ORDER BY joined_at")) {
statement.setString(1, group.getId().toString());
try (ResultSet resultSet = statement.executeQuery()) {
while (resultSet.next()) {
group.getMembers().add(UUID.fromString(resultSet.getString("player_uuid")));
}
}
}
}
return groups;
}
public void insertGroup(@Nonnull Group group) throws SQLException {
try (PreparedStatement statement = connection().prepareStatement(
"INSERT OR REPLACE INTO groups (id, owner_uuid, created_at) VALUES (?, ?, ?)")) {
statement.setString(1, group.getId().toString());
statement.setString(2, group.getOwner().toString());
statement.setLong(3, group.getCreatedAt());
statement.executeUpdate();
}
}
public void deleteGroup(@Nonnull UUID groupId) throws SQLException {
try (PreparedStatement members = connection().prepareStatement(
"DELETE FROM group_members WHERE group_id = ?")) {
members.setString(1, groupId.toString());
members.executeUpdate();
}
try (PreparedStatement statement = connection().prepareStatement(
"DELETE FROM groups WHERE id = ?")) {
statement.setString(1, groupId.toString());
statement.executeUpdate();
}
}
public void addMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException {
try (PreparedStatement statement = connection().prepareStatement(
"INSERT OR REPLACE INTO group_members (group_id, player_uuid, joined_at) VALUES (?, ?, ?)")) {
statement.setString(1, groupId.toString());
statement.setString(2, player.toString());
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate();
}
}
public void removeMember(@Nonnull UUID groupId, @Nonnull UUID player) throws SQLException {
try (PreparedStatement statement = connection().prepareStatement(
"DELETE FROM group_members WHERE group_id = ? AND player_uuid = ?")) {
statement.setString(1, groupId.toString());
statement.setString(2, player.toString());
statement.executeUpdate();
}
}
@Nonnull
private Connection connection() {
return databaseManager.getConnection();
}
}
@@ -0,0 +1,148 @@
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 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();
}
}
@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();
}
}
@@ -0,0 +1,113 @@
package com.disklexar.mmorpg.persistence.schema;
import javax.annotation.Nonnull;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import java.util.stream.Collectors;
/**
* Applies ordered SQL migrations from classpath resources.
* <p>
* Each migration is applied at most once. A {@code schema_migrations} table records which
* migrations already ran, so new files can be added without re-running previous ones (which
* matters for non-idempotent statements such as {@code ALTER TABLE ... ADD COLUMN}).
*/
public final class SchemaInitializer {
private static final List<String> MIGRATIONS = List.of(
"001_init.sql",
"002_rpg.sql");
private SchemaInitializer() {
}
public static void apply(@Nonnull Connection connection) throws SQLException {
ensureMigrationsTable(connection);
for (String migration : MIGRATIONS) {
if (isApplied(connection, migration)) {
continue;
}
runMigration(connection, migration);
markApplied(connection, migration);
}
}
private static void ensureMigrationsTable(@Nonnull Connection connection) throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
id TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
)
""");
}
}
private static boolean isApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT 1 FROM schema_migrations WHERE id = ?")) {
statement.setString(1, migration);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next();
}
}
}
private static void markApplied(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)")) {
statement.setString(1, migration);
statement.setLong(2, System.currentTimeMillis());
statement.executeUpdate();
}
}
private static void runMigration(@Nonnull Connection connection, @Nonnull String migration) throws SQLException {
String sql = loadMigration("/db/migrations/" + migration);
try (Statement statement = connection.createStatement()) {
for (String chunk : sql.split(";")) {
String trimmed = stripComments(chunk).trim();
if (!trimmed.isEmpty()) {
statement.execute(trimmed);
}
}
}
}
@Nonnull
private static String stripComments(@Nonnull String sql) {
StringBuilder builder = new StringBuilder();
for (String line : sql.split("\n")) {
String trimmed = line.trim();
if (trimmed.startsWith("--")) {
continue;
}
builder.append(line).append('\n');
}
return builder.toString();
}
@Nonnull
private static String loadMigration(@Nonnull String resourcePath) throws SQLException {
try (InputStream input = SchemaInitializer.class.getResourceAsStream(resourcePath)) {
if (input == null) {
throw new SQLException("Migration resource not found: " + resourcePath);
}
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(input, StandardCharsets.UTF_8))) {
return reader.lines().collect(Collectors.joining("\n"));
}
} catch (IOException e) {
throw new SQLException("Failed to read migration: " + resourcePath, e);
}
}
}
@@ -0,0 +1,19 @@
package com.disklexar.mmorpg.player;
import com.hypixel.hytale.component.Ref;
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.util.UUID;
/**
* A snapshot of the runtime handles for an online player, used to apply live effects.
*/
public record OnlinePlayer(
@Nonnull UUID uuid,
@Nonnull PlayerRef playerRef,
@Nonnull Ref<EntityStore> entityRef,
@Nonnull World world) {
}
@@ -0,0 +1,35 @@
package com.disklexar.mmorpg.player;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.Collection;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Tracks runtime handles ({@link OnlinePlayer}) for connected players so services can apply
* live effects (movement buffs, abilities) by UUID.
*/
public final class OnlinePlayerRegistry {
private final Map<UUID, OnlinePlayer> online = new ConcurrentHashMap<>();
public void add(@Nonnull OnlinePlayer player) {
online.put(player.uuid(), player);
}
public void remove(@Nonnull UUID uuid) {
online.remove(uuid);
}
@Nullable
public OnlinePlayer get(@Nonnull UUID uuid) {
return online.get(uuid);
}
@Nonnull
public Collection<OnlinePlayer> all() {
return online.values();
}
}
@@ -0,0 +1,115 @@
package com.disklexar.mmorpg.player;
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.job.JobCatalog;
import com.disklexar.mmorpg.rpg.job.JobDefinition;
import com.disklexar.mmorpg.rpg.power.PowerCatalog;
import com.disklexar.mmorpg.rpg.power.PowerDefinition;
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
import javax.annotation.Nonnull;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.StringJoiner;
/**
* Builds a human-readable summary of every MMORPG attribute of a player. Shared by the chat
* {@code /mmorpg info} command and the custom UI page so both stay consistent.
*/
public final class PlayerInfoView {
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm").withZone(ZoneId.systemDefault());
public record Entry(@Nonnull String label, @Nonnull String value) {
}
private 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("Niveau", String.valueOf(profile.getLevel())));
entries.add(new Entry("Expérience", String.valueOf(profile.getExperience())));
entries.add(new Entry("Argent", profile.getMoney() + " money"));
entries.add(new Entry("Race", RaceCatalog.byId(profile.getRaceId()).displayName()));
entries.add(new Entry("Classe", className(profile)));
entries.add(new Entry("Pouvoirs", powerNames(profile)));
entries.add(new Entry("Métiers", jobNames(profile)));
entries.add(new Entry("Groupe", profile.getGroupId() != null ? "Oui" : "Aucun"));
entries.add(new Entry("Guilde", profile.getGuildId() != null ? profile.getGuildId() : "Aucune"));
entries.add(new Entry("Connecté", profile.isConnected() ? "Oui" : "Non"));
entries.add(new Entry("Temps de jeu", formatDuration(profile.getTotalTimePlay())));
entries.add(new Entry("Dernière connexion", formatDate(profile.getLastDateConnected())));
entries.add(new Entry("Création", formatDate(profile.getDateCreation())));
return entries;
}
@Nonnull
public static String asText(@Nonnull PlayerProfile profile) {
StringBuilder builder = new StringBuilder("=== Profil MMORPG ===");
for (Entry entry : entries(profile)) {
builder.append('\n').append(entry.label()).append(" : ").append(entry.value());
}
return builder.toString();
}
@Nonnull
private static String className(@Nonnull PlayerProfile profile) {
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
return playerClass != null ? playerClass.displayName() : "Aucune";
}
@Nonnull
private static String powerNames(@Nonnull PlayerProfile profile) {
if (profile.getPowers().isEmpty()) {
return "Aucun";
}
StringJoiner joiner = new StringJoiner(", ");
for (String id : profile.getPowers()) {
PowerDefinition power = PowerCatalog.byId(id);
joiner.add(power != null ? power.displayName() : id);
}
return joiner.toString();
}
@Nonnull
private static String jobNames(@Nonnull PlayerProfile profile) {
if (profile.getJobs().isEmpty()) {
return "Aucun";
}
StringJoiner joiner = new StringJoiner(", ");
for (String id : profile.getJobs()) {
JobDefinition job = JobCatalog.byId(id);
joiner.add(job != null ? job.displayName() : id);
}
return joiner.toString();
}
@Nonnull
private static String formatDuration(long millis) {
if (millis <= 0) {
return "0 min";
}
Duration duration = Duration.ofMillis(millis);
long hours = duration.toHours();
long minutes = duration.toMinutesPart();
return hours > 0 ? hours + "h " + minutes + "min" : minutes + "min";
}
@Nonnull
private static String formatDate(long epochMillis) {
if (epochMillis <= 0) {
return "";
}
return DATE_FORMAT.format(Instant.ofEpochMilli(epochMillis));
}
}
@@ -0,0 +1,122 @@
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.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
/**
* Tracks online player sessions and synchronizes profiles with SQLite.
*/
public final class PlayerSessionService implements Service {
private final MmorpgPlugin plugin;
private final MmorpgConfig config;
private final PlayerProfileRepository repository;
private final HytaleLogger logger;
private final Map<UUID, PlayerProfile> sessions = new ConcurrentHashMap<>();
private final Map<UUID, Long> connectedAt = new ConcurrentHashMap<>();
public PlayerSessionService(
@Nonnull MmorpgPlugin plugin,
@Nonnull MmorpgConfig config,
@Nonnull PlayerProfileRepository repository) {
this.plugin = plugin;
this.config = config;
this.repository = repository;
this.logger = plugin.getLogger();
}
@Override
public void start() {
try {
repository.clearConnectedFlags();
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to reset connection flags: %s", e.getMessage());
}
logger.at(Level.INFO).log("Player session service started");
}
@Override
public void shutdown() {
for (PlayerProfile profile : sessions.values()) {
accumulatePlaytime(profile.getUuid(), profile);
profile.setConnected(false);
saveQuietly(profile);
}
sessions.clear();
connectedAt.clear();
logger.at(Level.INFO).log("Player session service stopped");
}
/**
* Loads (or creates) a profile and marks the player connected.
*/
@Nonnull
public PlayerProfile loadOrCreate(@Nonnull UUID uuid, @Nonnull String displayName) throws SQLException {
long now = System.currentTimeMillis();
Optional<PlayerProfile> existing = repository.findByUuid(uuid);
PlayerProfile profile;
if (existing.isPresent()) {
profile = existing.get();
profile.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);
}
profile.setConnected(true);
profile.setLastDateConnected(now);
profile.touch();
sessions.put(uuid, profile);
connectedAt.put(uuid, now);
saveQuietly(profile);
return profile;
}
@Nullable
public PlayerProfile getSession(@Nonnull UUID uuid) {
return sessions.get(uuid);
}
public void unload(@Nonnull UUID uuid) {
PlayerProfile profile = sessions.remove(uuid);
if (profile == 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);
}
}
private void saveQuietly(@Nonnull PlayerProfile profile) {
try {
repository.save(profile);
} catch (SQLException e) {
logger.at(Level.WARNING).log(
"Failed to save profile for %s: %s", profile.getUuid(), e.getMessage());
}
}
}
@@ -0,0 +1,175 @@
package com.disklexar.mmorpg.player.model;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashSet;
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.
*/
public final class PlayerProfile {
private final UUID uuid;
private String displayName;
private int level;
private long experience;
@Nullable
private String classId;
private final Set<String> powers = new LinkedHashSet<>();
private final Set<String> jobs = new LinkedHashSet<>();
@Nullable
private String guildId;
@Nullable
private String groupId;
private boolean connected;
private long totalTimePlay;
private long lastDateConnected;
private long money;
private String raceId = "human";
private final long dateCreation;
private long updatedAt;
public PlayerProfile(@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;
}
public int getLevel() {
return level;
}
public void setLevel(int level) {
this.level = level;
}
public long getExperience() {
return experience;
}
public void setExperience(long experience) {
this.experience = experience;
}
@Nullable
public String getClassId() {
return classId;
}
public void setClassId(@Nullable String classId) {
this.classId = classId;
}
@Nonnull
public Set<String> getPowers() {
return powers;
}
@Nonnull
public Set<String> getJobs() {
return jobs;
}
@Nullable
public String getGuildId() {
return guildId;
}
public void setGuildId(@Nullable String guildId) {
this.guildId = guildId;
}
@Nullable
public String getGroupId() {
return groupId;
}
public void setGroupId(@Nullable String groupId) {
this.groupId = groupId;
}
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 getMoney() {
return money;
}
public void setMoney(long money) {
this.money = Math.max(0L, money);
}
@Nonnull
public String getRaceId() {
return raceId;
}
public void setRaceId(@Nonnull String raceId) {
this.raceId = raceId;
}
public long getDateCreation() {
return dateCreation;
}
public long getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(long updatedAt) {
this.updatedAt = updatedAt;
}
/** Refreshes {@link #updatedAt} to now; call after mutating any field. */
public void touch() {
this.updatedAt = System.currentTimeMillis();
}
}
@@ -0,0 +1,53 @@
package com.disklexar.mmorpg.progression;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.rpg.race.RaceCatalog;
import com.disklexar.mmorpg.rpg.race.RaceDefinition;
import javax.annotation.Nonnull;
/**
* Experience and leveling. Applies the player's race XP multiplier (Humain x2).
*/
public final class ProgressionService {
private final int baseXpPerLevel;
public ProgressionService(int baseXpPerLevel) {
this.baseXpPerLevel = Math.max(1, baseXpPerLevel);
}
/** XP required to advance from {@code level} to {@code level + 1}. */
public long xpForLevel(int level) {
return (long) baseXpPerLevel * Math.max(1, level);
}
/**
* Grants {@code baseAmount} XP, scaled by the player's race multiplier when requested.
*
* @return the amount of XP actually added (after multiplier).
*/
public long grantExperience(@Nonnull PlayerProfile profile, long baseAmount, boolean applyRaceMultiplier) {
if (baseAmount <= 0) {
return 0L;
}
long granted = baseAmount;
if (applyRaceMultiplier) {
RaceDefinition race = RaceCatalog.byId(profile.getRaceId());
granted = Math.round(baseAmount * race.xpMultiplier());
}
profile.setExperience(profile.getExperience() + granted);
applyLevelUps(profile);
profile.touch();
return granted;
}
private void applyLevelUps(@Nonnull PlayerProfile profile) {
long needed = xpForLevel(profile.getLevel());
while (profile.getExperience() >= needed) {
profile.setExperience(profile.getExperience() - needed);
profile.setLevel(profile.getLevel() + 1);
needed = xpForLevel(profile.getLevel());
}
}
}
@@ -0,0 +1,7 @@
/**
* Quest system (objectives, rewards, quest chains).
* <p>
* Disabled by default via {@code Features.Quests} in config.json.
* Planned: quest definitions, progress tracking, daily quests.
*/
package com.disklexar.mmorpg.quest;
@@ -0,0 +1,14 @@
package com.disklexar.mmorpg.rpg.clazz;
import javax.annotation.Nonnull;
/**
* A single class ability (active skill) with its cooldown.
*/
public record AbilityDefinition(
@Nonnull String id,
int slot,
@Nonnull String displayName,
@Nonnull String description,
long cooldownMillis) {
}
@@ -0,0 +1,119 @@
package com.disklexar.mmorpg.rpg.clazz;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Registry of all playable classes.
*/
public final class ClassCatalog {
public static final String WEAPON_LONGSWORD = "LongSword";
public static final String WEAPON_BATTLEAXE = "BattleAxe";
public static final String WEAPON_BOW = "Bow";
public static final String WEAPON_CROSSBOW = "Crossbow";
public static final String WEAPON_STAFF = "Staff";
public static final String WEAPON_DAGGERS = "Daggers";
public static final String ABILITY_DASH = "knight_dash";
public static final String ABILITY_SPIN = "knight_spin";
public static final String ABILITY_SHOUT = "knight_shout";
public static final String ABILITY_ARCHER_RAPIDFIRE = "archer_rapidfire";
public static final String ABILITY_ARCHER_VOLLEY = "archer_volley";
public static final String ABILITY_ARCHER_POWERSHOT = "archer_powershot";
public static final String ABILITY_MAGE_FIREBALLS = "mage_fireballs";
public static final String ABILITY_MAGE_TELEPORT = "mage_teleport";
public static final String ABILITY_MAGE_TORNADO = "mage_tornado";
public static final String ABILITY_ASSASSIN_CAMOUFLAGE = "assassin_camouflage";
public static final String ABILITY_ASSASSIN_SHADOWSTEP = "assassin_shadowstep";
public static final String ABILITY_ASSASSIN_POISONBLADE = "assassin_poisonblade";
public static final PlayerClass KNIGHT = new PlayerClass(
"knight",
"Chevalier",
"Combattant lourd au corps-à-corps. Utilise l'Épée longue et la Hache de bataille.",
List.of(WEAPON_LONGSWORD, WEAPON_BATTLEAXE),
List.of(
new AbilityDefinition(ABILITY_DASH, 1, "Charge",
"Petit dash en avant qui étourdit 3s tous les ennemis touchés.", 8_000L),
new AbilityDefinition(ABILITY_SPIN, 2, "Toupie",
"Tournoiement infligeant des dégâts autour de vous pendant 3s.", 12_000L),
new AbilityDefinition(ABILITY_SHOUT, 3, "Cri de guerre",
"Repousse les entités proches (dégâts bonus si étourdies) et réduit leur résistance de 20% pendant 20s.",
15_000L)));
public static final PlayerClass ARCHER = new PlayerClass(
"archer",
"Archer",
"Tireur à distance. Utilise l'Arc et l'Arbalète.",
List.of(WEAPON_BOW, WEAPON_CROSSBOW),
List.of(
new AbilityDefinition(ABILITY_ARCHER_RAPIDFIRE, 1, "Tir rapide",
"Augmente votre cadence de tir (+20% de dégâts) pendant 10s.", 12_000L),
new AbilityDefinition(ABILITY_ARCHER_VOLLEY, 2, "Salve",
"Bond en arrière puis décoche instantanément 6 flèches devant vous.", 10_000L),
new AbilityDefinition(ABILITY_ARCHER_POWERSHOT, 3, "Tir puissant",
"Votre prochaine attaque de base inflige +200% de dégâts.", 9_000L)));
public static final PlayerClass MAGE = new PlayerClass(
"mage",
"Mage",
"Lanceur de sorts à distance. Utilise le Bâton.",
List.of(WEAPON_STAFF),
List.of(
new AbilityDefinition(ABILITY_MAGE_FIREBALLS, 1, "Nova de Givre",
"Envoie instantanément 3 boules de feu vers l'avant.", 8_000L),
new AbilityDefinition(ABILITY_MAGE_TELEPORT, 2, "Téléportation",
"Vous téléporte instantanément de 8 blocs vers l'avant.", 10_000L),
new AbilityDefinition(ABILITY_MAGE_TORNADO, 3, "Surcharge Arcane",
"Crée une tornade dévastatrice 15 blocs devant vous.", 16_000L)));
public static final PlayerClass ASSASSIN = new PlayerClass(
"assassin",
"Assassin",
"Tueur furtif au corps-à-corps. Utilise les Dagues.",
List.of(WEAPON_DAGGERS),
List.of(
new AbilityDefinition(ABILITY_ASSASSIN_CAMOUFLAGE, 1, "Camouflage",
"Devient totalement invisible pendant 6s.", 14_000L),
new AbilityDefinition(ABILITY_ASSASSIN_SHADOWSTEP, 2, "Pas de l'Ombre",
"Se téléporte instantanément dans le dos de la cible visée.", 9_000L),
new AbilityDefinition(ABILITY_ASSASSIN_POISONBLADE, 3, "Lame Empoisonnée",
"Pendant 10s, chaque coup applique un poison (dégâts magiques/seconde pendant 5s, cumulable).",
13_000L)));
private static final Map<String, PlayerClass> BY_ID = new LinkedHashMap<>();
static {
register(KNIGHT);
register(ARCHER);
register(MAGE);
register(ASSASSIN);
}
private ClassCatalog() {
}
private static void register(@Nonnull PlayerClass playerClass) {
BY_ID.put(playerClass.id().toLowerCase(), playerClass);
}
@Nullable
public static PlayerClass byId(@Nullable String id) {
if (id == null) {
return null;
}
return BY_ID.get(id.toLowerCase());
}
@Nonnull
public static List<PlayerClass> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -0,0 +1,45 @@
package com.disklexar.mmorpg.rpg.clazz;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Selecting / leaving a player class.
*/
public final class ClassService {
@Nullable
public PlayerClass getClass(@Nonnull PlayerProfile profile) {
return ClassCatalog.byId(profile.getClassId());
}
public boolean hasClass(@Nonnull PlayerProfile profile) {
return getClass(profile) != null;
}
/**
* Assigns a class to the player.
*
* @return {@code true} if the class exists and was assigned.
*/
public boolean choose(@Nonnull PlayerProfile profile, @Nonnull String classId) {
PlayerClass playerClass = ClassCatalog.byId(classId);
if (playerClass == null) {
return false;
}
profile.setClassId(playerClass.id());
profile.touch();
return true;
}
public boolean leave(@Nonnull PlayerProfile profile) {
if (profile.getClassId() == null) {
return false;
}
profile.setClassId(null);
profile.touch();
return true;
}
}
@@ -0,0 +1,24 @@
package com.disklexar.mmorpg.rpg.clazz;
import javax.annotation.Nonnull;
import java.util.List;
/**
* Definition of a playable class: its usable weapons and its abilities.
*/
public record PlayerClass(
@Nonnull String id,
@Nonnull String displayName,
@Nonnull String description,
@Nonnull List<String> weapons,
@Nonnull List<AbilityDefinition> abilities) {
public boolean usesWeapon(@Nonnull String weaponId) {
for (String weapon : weapons) {
if (weapon.equalsIgnoreCase(weaponId)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,61 @@
package com.disklexar.mmorpg.rpg.group;
import javax.annotation.Nonnull;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* A party of players. When a member kills a mob, the other members receive a share of the XP.
*/
public final class Group {
private final UUID id;
private final UUID owner;
private final long createdAt;
private final Set<UUID> members = ConcurrentHashMap.newKeySet();
private final Set<UUID> pendingInvites = ConcurrentHashMap.newKeySet();
public Group(@Nonnull UUID id, @Nonnull UUID owner, long createdAt) {
this.id = id;
this.owner = owner;
this.createdAt = createdAt;
this.members.add(owner);
}
@Nonnull
public UUID getId() {
return id;
}
@Nonnull
public UUID getOwner() {
return owner;
}
public long getCreatedAt() {
return createdAt;
}
@Nonnull
public Set<UUID> getMembers() {
return members;
}
@Nonnull
public Set<UUID> getPendingInvites() {
return pendingInvites;
}
public boolean isOwner(@Nonnull UUID uuid) {
return owner.equals(uuid);
}
@Nonnull
public Set<UUID> membersExcept(@Nonnull UUID uuid) {
Set<UUID> others = new LinkedHashSet<>(members);
others.remove(uuid);
return others;
}
}
@@ -0,0 +1,257 @@
package com.disklexar.mmorpg.rpg.group;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.core.service.Service;
import com.disklexar.mmorpg.persistence.repository.GroupRepository;
import com.disklexar.mmorpg.player.PlayerSessionService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.hypixel.hytale.logger.HytaleLogger;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.sql.SQLException;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
/**
* Manages parties (groups) and shared kill experience.
* <p>
* When a member kills a mob, the other online members receive
* {@value #SHARED_XP_FRACTION_PERCENT}% of the XP the killer gained.
*/
public final class GroupService implements Service {
public static final int SHARED_XP_FRACTION_PERCENT = 40;
private static final double SHARED_XP_FRACTION = SHARED_XP_FRACTION_PERCENT / 100.0;
private final HytaleLogger logger;
private final GroupRepository repository;
private final PlayerSessionService sessions;
private final ProgressionService progression;
private final Map<UUID, Group> groupsById = new ConcurrentHashMap<>();
private final Map<UUID, UUID> groupByPlayer = new ConcurrentHashMap<>();
public GroupService(
@Nonnull MmorpgPlugin plugin,
@Nonnull GroupRepository repository,
@Nonnull PlayerSessionService sessions,
@Nonnull ProgressionService progression) {
this.logger = plugin.getLogger();
this.repository = repository;
this.sessions = sessions;
this.progression = progression;
}
@Override
public void start() {
try {
for (Group group : repository.loadAll()) {
groupsById.put(group.getId(), group);
for (UUID member : group.getMembers()) {
groupByPlayer.put(member, group.getId());
}
}
logger.at(Level.INFO).log("Loaded %d MMORPG groups", groupsById.size());
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to load groups: %s", e.getMessage());
}
}
@Override
public void shutdown() {
groupsById.clear();
groupByPlayer.clear();
}
@Nullable
public Group getGroupOf(@Nonnull UUID player) {
UUID groupId = groupByPlayer.get(player);
return groupId == null ? null : groupsById.get(groupId);
}
@Nullable
public Group getById(@Nonnull UUID groupId) {
return groupsById.get(groupId);
}
/** Invites {@code target} to {@code inviter}'s group, creating the group if needed. */
@Nonnull
public InviteResult invite(@Nonnull UUID inviter, @Nonnull UUID target) {
if (inviter.equals(target)) {
return InviteResult.SELF;
}
if (getGroupOf(target) != null) {
return InviteResult.TARGET_ALREADY_GROUPED;
}
Group group = getGroupOf(inviter);
if (group == null) {
group = createGroup(inviter);
} else if (!group.isOwner(inviter)) {
return InviteResult.NOT_OWNER;
}
group.getPendingInvites().add(target);
return InviteResult.INVITED;
}
/** Accepts a pending invitation. */
@Nonnull
public AcceptResult accept(@Nonnull UUID player) {
if (getGroupOf(player) != null) {
return AcceptResult.ALREADY_GROUPED;
}
for (Group group : groupsById.values()) {
if (group.getPendingInvites().remove(player)) {
addMember(group, player);
return AcceptResult.JOINED;
}
}
return AcceptResult.NO_INVITE;
}
/** Removes the player from their group; disbands it if the owner leaves or it becomes empty. */
public boolean leave(@Nonnull UUID player) {
Group group = getGroupOf(player);
if (group == null) {
return false;
}
if (group.isOwner(player)) {
disband(group);
return true;
}
removeMember(group, player);
if (group.getMembers().size() <= 1) {
disband(group);
}
return true;
}
@Nonnull
public KickResult kick(@Nonnull UUID owner, @Nonnull UUID target) {
Group group = getGroupOf(owner);
if (group == null || !group.isOwner(owner)) {
return KickResult.NOT_OWNER;
}
if (owner.equals(target)) {
return KickResult.CANNOT_KICK_SELF;
}
if (!group.getMembers().contains(target)) {
return KickResult.NOT_MEMBER;
}
removeMember(group, target);
if (group.getMembers().size() <= 1) {
disband(group);
}
return KickResult.KICKED;
}
/**
* Distributes shared XP to the killer's online group mates.
*
* @return number of members who received XP.
*/
public int shareKillExperience(@Nonnull UUID killer, long killerGainedXp) {
if (killerGainedXp <= 0) {
return 0;
}
Group group = getGroupOf(killer);
if (group == null) {
return 0;
}
long shared = Math.round(killerGainedXp * SHARED_XP_FRACTION);
if (shared <= 0) {
return 0;
}
int rewarded = 0;
for (UUID member : group.membersExcept(killer)) {
PlayerProfile profile = sessions.getSession(member);
if (profile == null) {
continue;
}
progression.grantExperience(profile, shared, false);
rewarded++;
}
return rewarded;
}
@Nonnull
private Group createGroup(@Nonnull UUID owner) {
Group group = new Group(UUID.randomUUID(), owner, System.currentTimeMillis());
groupsById.put(group.getId(), group);
groupByPlayer.put(owner, group.getId());
persistGroup(group);
addMemberPersistence(group.getId(), owner);
setProfileGroup(owner, group.getId());
return group;
}
private void addMember(@Nonnull Group group, @Nonnull UUID player) {
group.getMembers().add(player);
groupByPlayer.put(player, group.getId());
addMemberPersistence(group.getId(), player);
setProfileGroup(player, group.getId());
}
private void removeMember(@Nonnull Group group, @Nonnull UUID player) {
group.getMembers().remove(player);
groupByPlayer.remove(player);
try {
repository.removeMember(group.getId(), player);
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to remove group member: %s", e.getMessage());
}
setProfileGroup(player, null);
}
private void disband(@Nonnull Group group) {
for (UUID member : group.getMembers()) {
groupByPlayer.remove(member);
setProfileGroup(member, null);
}
groupsById.remove(group.getId());
try {
repository.deleteGroup(group.getId());
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to delete group: %s", e.getMessage());
}
}
private void persistGroup(@Nonnull Group group) {
try {
repository.insertGroup(group);
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to persist group: %s", e.getMessage());
}
}
private void addMemberPersistence(@Nonnull UUID groupId, @Nonnull UUID player) {
try {
repository.addMember(groupId, player);
} catch (SQLException e) {
logger.at(Level.WARNING).log("Failed to persist group member: %s", e.getMessage());
}
}
private void setProfileGroup(@Nonnull UUID player, @Nullable UUID groupId) {
PlayerProfile profile = sessions.getSession(player);
if (profile != null) {
profile.setGroupId(groupId == null ? null : groupId.toString());
profile.touch();
}
}
public enum InviteResult {
INVITED, SELF, NOT_OWNER, TARGET_ALREADY_GROUPED
}
public enum AcceptResult {
JOINED, NO_INVITE, ALREADY_GROUPED
}
public enum KickResult {
KICKED, NOT_OWNER, NOT_MEMBER, CANNOT_KICK_SELF
}
}
@@ -0,0 +1,55 @@
package com.disklexar.mmorpg.rpg.job;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Registry of all jobs.
*/
public final class JobCatalog {
/** Money granted per mob killed while the Monster Slayer job is active. */
public static final long MONSTER_SLAYER_REWARD = 1L;
/** Money granted per block broken while the Miner job is active. */
public static final long MINER_REWARD = 1L;
public static final JobDefinition MONSTER_SLAYER = new JobDefinition(
"monster_slayer",
"Tueur de monstre",
"Gagne 1 money à chaque monstre tué.");
public static final JobDefinition MINER = new JobDefinition(
"miner",
"Mineur",
"Gagne 1 money à chaque bloc cassé.");
private static final Map<String, JobDefinition> BY_ID = new LinkedHashMap<>();
static {
register(MONSTER_SLAYER);
register(MINER);
}
private JobCatalog() {
}
private static void register(@Nonnull JobDefinition job) {
BY_ID.put(job.id().toLowerCase(), job);
}
@Nullable
public static JobDefinition byId(@Nullable String id) {
if (id == null) {
return null;
}
return BY_ID.get(id.toLowerCase());
}
@Nonnull
public static List<JobDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -0,0 +1,12 @@
package com.disklexar.mmorpg.rpg.job;
import javax.annotation.Nonnull;
/**
* A job a player can join to earn money from a specific activity.
*/
public record JobDefinition(
@Nonnull String id,
@Nonnull String displayName,
@Nonnull String description) {
}
@@ -0,0 +1,49 @@
package com.disklexar.mmorpg.rpg.job;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import javax.annotation.Nonnull;
/**
* Joining / leaving jobs.
*/
public final class JobService {
public boolean has(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
return profile.getJobs().contains(jobId.toLowerCase());
}
/**
* Joins a job.
*
* @return {@code true} if the job exists and was newly joined.
*/
public boolean join(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
JobDefinition job = JobCatalog.byId(jobId);
if (job == null) {
return false;
}
boolean added = profile.getJobs().add(job.id());
if (added) {
profile.touch();
}
return added;
}
/**
* Leaves a job.
*
* @return {@code true} if the job was active and was left.
*/
public boolean leave(@Nonnull PlayerProfile profile, @Nonnull String jobId) {
JobDefinition job = JobCatalog.byId(jobId);
if (job == null) {
return false;
}
boolean removed = profile.getJobs().remove(job.id());
if (removed) {
profile.touch();
}
return removed;
}
}
@@ -0,0 +1,57 @@
package com.disklexar.mmorpg.rpg.power;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Registry of all passive powers.
*/
public final class PowerCatalog {
/** Constant out-of-combat movement bonus. */
public static final double SPRINTER_SPEED_BONUS = 0.10;
/** Damage bonus applied for a short window after being hit. */
public static final double RESILIENT_DAMAGE_BONUS = 0.10;
/** Window during which {@link #RESILIENT} grants its bonus, in milliseconds. */
public static final long RESILIENT_WINDOW_MILLIS = 5_000L;
public static final PowerDefinition SPRINTER = new PowerDefinition(
"sprinter",
"Sprinteur",
"+10% de vitesse en permanence hors combat.");
public static final PowerDefinition RESILIENT = new PowerDefinition(
"resilient",
"Résilient",
"+10% de dégâts pendant 5s après avoir été touché.");
private static final Map<String, PowerDefinition> BY_ID = new LinkedHashMap<>();
static {
register(SPRINTER);
register(RESILIENT);
}
private PowerCatalog() {
}
private static void register(@Nonnull PowerDefinition power) {
BY_ID.put(power.id().toLowerCase(), power);
}
@Nullable
public static PowerDefinition byId(@Nullable String id) {
if (id == null) {
return null;
}
return BY_ID.get(id.toLowerCase());
}
@Nonnull
public static List<PowerDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -0,0 +1,12 @@
package com.disklexar.mmorpg.rpg.power;
import javax.annotation.Nonnull;
/**
* A passive power a player can own. Players start with none.
*/
public record PowerDefinition(
@Nonnull String id,
@Nonnull String displayName,
@Nonnull String description) {
}
@@ -0,0 +1,49 @@
package com.disklexar.mmorpg.rpg.power;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import javax.annotation.Nonnull;
/**
* Granting / removing passive powers. Players start with none.
*/
public final class PowerService {
public boolean has(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
return profile.getPowers().contains(powerId.toLowerCase());
}
/**
* Grants a power.
*
* @return {@code true} if the power exists and was newly added.
*/
public boolean grant(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
PowerDefinition power = PowerCatalog.byId(powerId);
if (power == null) {
return false;
}
boolean added = profile.getPowers().add(power.id());
if (added) {
profile.touch();
}
return added;
}
/**
* Removes a power.
*
* @return {@code true} if the power was present and removed.
*/
public boolean remove(@Nonnull PlayerProfile profile, @Nonnull String powerId) {
PowerDefinition power = PowerCatalog.byId(powerId);
if (power == null) {
return false;
}
boolean removed = profile.getPowers().remove(power.id());
if (removed) {
profile.touch();
}
return removed;
}
}
@@ -0,0 +1,46 @@
package com.disklexar.mmorpg.rpg.race;
import javax.annotation.Nonnull;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Registry of all races. Every player defaults to {@link #HUMAN}.
*/
public final class RaceCatalog {
public static final String DEFAULT_RACE_ID = "human";
public static final RaceDefinition HUMAN = new RaceDefinition(
DEFAULT_RACE_ID,
"Humain",
"Race par défaut. Gagne le double d'expérience.",
2.0);
private static final Map<String, RaceDefinition> BY_ID = new LinkedHashMap<>();
static {
register(HUMAN);
}
private RaceCatalog() {
}
private static void register(@Nonnull RaceDefinition race) {
BY_ID.put(race.id().toLowerCase(), race);
}
@Nonnull
public static RaceDefinition byId(String id) {
if (id == null) {
return HUMAN;
}
return BY_ID.getOrDefault(id.toLowerCase(), HUMAN);
}
@Nonnull
public static List<RaceDefinition> all() {
return List.copyOf(BY_ID.values());
}
}
@@ -0,0 +1,13 @@
package com.disklexar.mmorpg.rpg.race;
import javax.annotation.Nonnull;
/**
* A playable race with its global XP multiplier.
*/
public record RaceDefinition(
@Nonnull String id,
@Nonnull String displayName,
@Nonnull String description,
double xpMultiplier) {
}
@@ -0,0 +1,35 @@
package com.disklexar.mmorpg.rpg.race;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import javax.annotation.Nonnull;
/**
* Race access. Every player defaults to Humain (set at profile creation).
*/
public final class RaceService {
@Nonnull
public RaceDefinition getRace(@Nonnull PlayerProfile profile) {
return RaceCatalog.byId(profile.getRaceId());
}
/** Ensures the profile has a valid race, defaulting to Humain. */
public void ensureDefault(@Nonnull PlayerProfile profile) {
if (RaceCatalog.byId(profile.getRaceId()) == RaceCatalog.HUMAN
&& !RaceCatalog.DEFAULT_RACE_ID.equals(profile.getRaceId())) {
profile.setRaceId(RaceCatalog.DEFAULT_RACE_ID);
profile.touch();
}
}
public boolean setRace(@Nonnull PlayerProfile profile, @Nonnull String raceId) {
RaceDefinition race = RaceCatalog.byId(raceId);
if (!race.id().equalsIgnoreCase(raceId)) {
return false;
}
profile.setRaceId(race.id());
profile.touch();
return true;
}
}
@@ -0,0 +1,7 @@
/**
* Social systems (parties, guilds, friends).
* <p>
* Disabled by default via {@code Features.Guilds} in config.json.
* Planned: guild creation, ranks, shared storage, party instances.
*/
package com.disklexar.mmorpg.social;
@@ -0,0 +1,83 @@
package com.disklexar.mmorpg.ui;
import com.disklexar.mmorpg.combat.AbilityService;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.disklexar.mmorpg.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.clazz.AbilityDefinition;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.server.core.entity.entities.player.hud.CustomUIHud;
import com.hypixel.hytale.server.core.ui.builder.UICommandBuilder;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.util.UUID;
/**
* Persistent MMORPG HUD: vitals (health, stamina, XP) and class ability bar (top-left).
*/
public final class AbilityBarHud extends CustomUIHud {
public static final String KEY = "mmorpg_ability_bar";
private static final String DOCUMENT = "MmorpgAbilityBar.ui";
public AbilityBarHud(@Nonnull PlayerRef playerRef) {
super(playerRef, KEY);
}
@Override
protected void build(@Nonnull UICommandBuilder ui) {
ui.append(DOCUMENT);
}
public void sync(
@Nonnull PlayerProfile profile,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityService abilities,
@Nonnull ProgressionService progression,
@Nullable PlayerVitalsReader.Vitals vitals,
@Nonnull UUID playerId) {
UICommandBuilder ui = new UICommandBuilder();
ui.set("#HeaderLabel.Text", escape(
"Nv." + profile.getLevel() + "" + playerClass.displayName()));
if (vitals != null) {
applyBar(ui, "#HealthBar", vitals.current(), vitals.max());
ui.set("#HealthValue.Text", escape(vitals.current() + " / " + vitals.max()));
applyBar(ui, "#StaminaBar", vitals.staminaCurrent(), vitals.staminaMax());
ui.set("#StaminaValue.Text", escape(
vitals.staminaCurrent() + " / " + vitals.staminaMax()));
}
long xpCurrent = profile.getExperience();
long xpMax = Math.max(1L, progression.xpForLevel(profile.getLevel()));
applyBar(ui, "#XpBar", xpCurrent, xpMax);
ui.set("#XpValue.Text", escape(xpCurrent + " / " + xpMax));
for (AbilityDefinition ability : playerClass.abilities()) {
int slot = ability.slot();
long remaining = abilities.cooldownRemaining(playerId, ability.id());
String label = remaining > 0
? ability.displayName() + " (" + (remaining / 1000 + 1) + "s)"
: ability.displayName();
ui.set("#Slot" + slot + "Name.Text", escape(label));
}
update(false, ui);
}
private static void applyBar(
@Nonnull UICommandBuilder ui,
@Nonnull String barSelector,
long current,
long max) {
float value = max <= 0 ? 0f : Math.min(1f, (float) current / max);
ui.set(barSelector + ".Value", value);
}
@Nonnull
private static String escape(@Nonnull String text) {
return text.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,237 @@
package com.disklexar.mmorpg.ui;
import com.disklexar.mmorpg.MmorpgPlugin;
import com.disklexar.mmorpg.combat.AbilityService;
import com.disklexar.mmorpg.combat.MmorpgScheduler;
import com.disklexar.mmorpg.core.service.Service;
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.progression.ProgressionService;
import com.disklexar.mmorpg.rpg.clazz.ClassService;
import com.disklexar.mmorpg.rpg.clazz.PlayerClass;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.server.core.entity.entities.Player;
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.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.logging.Level;
/**
* Shows and refreshes the native ability-bar HUD for players with an MMORPG class.
*/
public final class AbilityBarService implements Service {
private final HytaleLogger logger;
private final ClassService classService;
private final AbilityService abilityService;
private final OnlinePlayerRegistry onlinePlayers;
private final PlayerSessionService sessions;
private final ProgressionService progression;
private final MmorpgScheduler scheduler;
private final Map<UUID, AbilityBarHud> activeHuds = new ConcurrentHashMap<>();
private ScheduledFuture<?> refreshTask;
public AbilityBarService(
@Nonnull MmorpgPlugin plugin,
@Nonnull ClassService classService,
@Nonnull AbilityService abilityService,
@Nonnull OnlinePlayerRegistry onlinePlayers,
@Nonnull PlayerSessionService sessions,
@Nonnull ProgressionService progression,
@Nonnull MmorpgScheduler scheduler) {
this.logger = plugin.getLogger();
this.classService = classService;
this.abilityService = abilityService;
this.onlinePlayers = onlinePlayers;
this.sessions = sessions;
this.progression = progression;
this.scheduler = scheduler;
}
@Override
public void start() {
refreshTask = scheduler.runRepeating(this::refreshAll, 5_000L, 1_000L);
}
@Override
public void shutdown() {
if (refreshTask != null) {
refreshTask.cancel(false);
refreshTask = null;
}
activeHuds.clear();
}
public void sync(@Nonnull UUID playerUuid, @Nonnull PlayerProfile profile) {
OnlinePlayer online = onlinePlayers.get(playerUuid);
if (online == null) {
return;
}
sync(online, profile);
}
public void sync(@Nonnull OnlinePlayer online, @Nonnull PlayerProfile profile) {
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
dismiss(online.uuid());
return;
}
online.world().execute(() -> applyHud(online, profile, playerClass));
}
public void dismiss(@Nonnull UUID playerUuid) {
OnlinePlayer online = onlinePlayers.get(playerUuid);
if (online == null) {
activeHuds.remove(playerUuid);
return;
}
dismiss(online);
}
public void dismiss(@Nonnull OnlinePlayer online) {
AbilityBarHud hud = activeHuds.remove(online.uuid());
if (hud == null) {
return;
}
online.world().execute(() -> {
Player player = resolvePlayer(online.world(), online.uuid());
if (player != null) {
player.getHudManager().removeCustomHud(online.playerRef(), AbilityBarHud.KEY);
}
});
}
private void refreshAll() {
for (OnlinePlayer online : onlinePlayers.all()) {
PlayerProfile profile = sessions.getSession(online.uuid());
if (profile == null || !classService.hasClass(profile)) {
if (activeHuds.containsKey(online.uuid())) {
dismiss(online.uuid());
}
continue;
}
AbilityBarHud hud = activeHuds.get(online.uuid());
if (hud == null) {
sync(online, profile);
continue;
}
PlayerClass playerClass = classService.getClass(profile);
if (playerClass == null) {
continue;
}
online.world().execute(() -> {
if (onlinePlayers.get(online.uuid()) != online) {
return;
}
if (resolvePlayer(online.world(), online.uuid()) == null) {
activeHuds.remove(online.uuid());
return;
}
pushUpdate(online, profile, playerClass, hud);
});
}
}
private void applyHud(
@Nonnull OnlinePlayer online,
@Nonnull PlayerProfile profile,
@Nonnull PlayerClass playerClass) {
try {
if (onlinePlayers.get(online.uuid()) == null) {
return;
}
Player player = resolvePlayer(online.world(), online.uuid());
if (player == null) {
logger.at(Level.FINE).log(
"Ability bar HUD deferred for %s: player not in world store yet",
online.playerRef().getUsername());
return;
}
AbilityBarHud hud = activeHuds.get(online.uuid());
if (hud == null) {
hud = new AbilityBarHud(online.playerRef());
player.getHudManager().addCustomHud(online.playerRef(), hud);
activeHuds.put(online.uuid(), hud);
logger.at(Level.INFO).log(
"MMORPG HUD shown for %s",
online.playerRef().getUsername());
}
pushUpdate(online, profile, playerClass, hud);
} catch (Throwable t) {
activeHuds.remove(online.uuid());
logger.at(Level.WARNING).log(
"Ability bar HUD failed for %s: %s",
online.playerRef().getUsername(),
t.getMessage());
}
}
private void pushUpdate(
@Nonnull OnlinePlayer online,
@Nonnull PlayerProfile profile,
@Nonnull PlayerClass playerClass,
@Nonnull AbilityBarHud hud) {
if (!activeHuds.containsKey(online.uuid()) || onlinePlayers.get(online.uuid()) == null) {
return;
}
World world = online.world();
Store<EntityStore> store = world.getEntityStore().getStore();
Ref<EntityStore> entityRef = resolveEntityRef(world, online.uuid());
PlayerVitalsReader.Vitals vitals = entityRef == null
? null
: PlayerVitalsReader.read(store, entityRef);
hud.sync(profile, playerClass, abilityService, progression, vitals, profile.getUuid());
}
@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;
}
/**
* Looks up the live {@link Player} from the world's player list instead of a cached
* {@link Ref} that may be unset or invalidated after {@code ClientReady}.
*/
@Nullable
private static Player resolvePlayer(@Nonnull World world, @Nonnull UUID playerUuid) {
Store<EntityStore> store = world.getEntityStore().getStore();
for (PlayerRef playerRef : world.getPlayerRefs()) {
if (!playerRef.getUuid().equals(playerUuid) || !playerRef.isValid()) {
continue;
}
Ref<EntityStore> entityRef = playerRef.getReference();
if (entityRef == null || !entityRef.isValid()) {
continue;
}
Player player = store.getComponent(entityRef, Player.getComponentType());
if (player != null) {
return player;
}
}
return null;
}
}
@@ -0,0 +1,94 @@
package com.disklexar.mmorpg.ui;
import com.disklexar.mmorpg.player.PlayerInfoView;
import com.disklexar.mmorpg.player.model.PlayerProfile;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.protocol.packets.interface_.CustomPageLifetime;
import com.hypixel.hytale.server.core.entity.entities.player.pages.CustomUIPage;
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 java.util.List;
/**
* Custom UI page listing every MMORPG attribute of the player.
* <p>
* Custom UI documents must already exist on the client (they ship in the game's {@code Assets.zip}),
* so we reuse the always-present {@code Pages/WarpListPage.ui} layout — a titled window with a
* scrolling list container ({@code #WarpList}) and a back button — and fill that container with one
* label per attribute via {@link UICommandBuilder#appendInline(String, String)}. This is the same
* mechanism the builtin pages use to inject rows, and it avoids the previous crash that came from
* passing inline markup to {@link UICommandBuilder#append(String)} (which expects a document path).
*/
public final class PlayerInfoPage extends CustomUIPage {
/** Reliable layout: reuses the always-present base-game document. */
private static final String RELIABLE_DOCUMENT = "Pages/WarpListPage.ui";
private static final String RELIABLE_LIST = "#WarpList";
/** Designed layout shipped in the plugin asset pack. */
private static final String CUSTOM_DOCUMENT = "Pages/MmorpgPlayerInfo.ui";
private static final String CUSTOM_LIST = "#ProfileList";
private final PlayerProfile profile;
private final boolean custom;
public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile) {
this(playerRef, profile, false);
}
public PlayerInfoPage(@Nonnull PlayerRef playerRef, @Nonnull PlayerProfile profile, boolean custom) {
super(playerRef, CustomPageLifetime.CanDismiss);
this.profile = profile;
this.custom = custom;
}
@Override
public void build(
@Nonnull Ref<EntityStore> ref,
@Nonnull UICommandBuilder ui,
@Nonnull UIEventBuilder events,
@Nonnull Store<EntityStore> store) {
String document = custom ? CUSTOM_DOCUMENT : RELIABLE_DOCUMENT;
String list = custom ? CUSTOM_LIST : RELIABLE_LIST;
ui.append(document);
ui.clear(list);
ui.appendInline(list, title("=== Profil MMORPG ==="));
List<PlayerInfoView.Entry> entries = PlayerInfoView.entries(profile);
for (PlayerInfoView.Entry entry : entries) {
ui.appendInline(list, row(entry.label(), entry.value()));
}
ui.appendInline(list, hint("(Échap pour fermer)"));
}
@Nonnull
private static String title(@Nonnull String text) {
return "Label { Text: \"" + escape(text)
+ "\"; Style: (HorizontalAlignment: Center, RenderBold: true, FontSize: 18);"
+ " Anchor: (Bottom: 8); }";
}
@Nonnull
private static String row(@Nonnull String label, @Nonnull String value) {
return "Label { Text: \"" + escape(label + " : " + value)
+ "\"; Style: (FontSize: 14, Wrap: true); Anchor: (Bottom: 3); }";
}
@Nonnull
private static String hint(@Nonnull String text) {
return "Label { Text: \"" + escape(text)
+ "\"; Style: (HorizontalAlignment: Center, FontSize: 12, TextColor: #878e9c);"
+ " Anchor: (Top: 8); }";
}
@Nonnull
private static String escape(@Nonnull String text) {
return text.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,45 @@
package com.disklexar.mmorpg.ui;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatMap;
import com.hypixel.hytale.server.core.modules.entitystats.EntityStatValue;
import com.hypixel.hytale.server.core.modules.entitystats.asset.DefaultEntityStatTypes;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* Reads live health and stamina from the vanilla entity stat map.
*/
public final class PlayerVitalsReader {
public record Vitals(int current, int max, int staminaCurrent, int staminaMax) {
}
private PlayerVitalsReader() {
}
@Nullable
public static Vitals read(
@Nonnull Store<EntityStore> store,
@Nonnull Ref<EntityStore> entityRef) {
EntityStatMap stats = store.getComponent(entityRef, EntityStatMap.getComponentType());
if (stats == null) {
return null;
}
EntityStatValue health = stats.get(DefaultEntityStatTypes.getHealth());
EntityStatValue stamina = stats.get(DefaultEntityStatTypes.getStamina());
if (health == null || stamina == null) {
return null;
}
return new Vitals(
Math.round(health.get()),
Math.round(health.getMax()),
Math.round(stamina.get()),
Math.round(stamina.getMax()));
}
}
@@ -0,0 +1,170 @@
@HealthBarFill = PatchStyle(Color: #e74c3c);
@HealthBarBg = PatchStyle(Color: #2a1515);
@StaminaBarFill = PatchStyle(Color: #2ecc71);
@StaminaBarBg = PatchStyle(Color: #152a1c);
@XpBarFill = PatchStyle(Color: #4a9eff);
@XpBarBg = PatchStyle(Color: #152030);
@VitalsPanelWidth = 260;
@VitalsBarWidth = 244;
@AbilitySlotSize = 68;
@AbilityPanelPad = 3;
@AbilityPanelWidth = @AbilitySlotSize + (@AbilityPanelPad * 2);
Group {
LayoutMode: Top;
Anchor: (Left: 16, Top: 16, Width: @VitalsPanelWidth);
Label #HeaderLabel {
Style: (FontSize: 14, RenderBold: true, TextColor: #f5c842);
Text: "Nv.1 — Classe";
Anchor: (Left: 0, Bottom: 8);
}
Group #VitalsPanel {
LayoutMode: Top;
Padding: (Full: 8);
Background: #0d0f14(0.92);
Anchor: (Left: 0, Width: @VitalsPanelWidth, Bottom: 8);
Group #HealthRow {
LayoutMode: Top;
Anchor: (Bottom: 6);
Label #HealthTitle {
Style: (FontSize: 10, RenderBold: true, TextColor: #ff7b7b);
Text: "Vie";
Anchor: (Bottom: 2);
}
ProgressBar #HealthBar {
Anchor: (Width: @VitalsBarWidth, Height: 12);
Bar: @HealthBarFill;
Background: @HealthBarBg;
Value: 1.0;
Direction: End;
}
Label #HealthValue {
Style: (FontSize: 9, TextColor: #f0f0f0);
Text: "0 / 0";
Anchor: (Top: 2);
}
}
Group #StaminaRow {
LayoutMode: Top;
Anchor: (Bottom: 6);
Label #StaminaTitle {
Style: (FontSize: 10, RenderBold: true, TextColor: #7bdc9a);
Text: "Endurance";
Anchor: (Bottom: 2);
}
ProgressBar #StaminaBar {
Anchor: (Width: @VitalsBarWidth, Height: 12);
Bar: @StaminaBarFill;
Background: @StaminaBarBg;
Value: 1.0;
Direction: End;
}
Label #StaminaValue {
Style: (FontSize: 9, TextColor: #f0f0f0);
Text: "0 / 0";
Anchor: (Top: 2);
}
}
Group #XpRow {
LayoutMode: Top;
Label #XpTitle {
Style: (FontSize: 10, RenderBold: true, TextColor: #8eb6ff);
Text: "Expérience";
Anchor: (Bottom: 2);
}
ProgressBar #XpBar {
Anchor: (Width: @VitalsBarWidth, Height: 12);
Bar: @XpBarFill;
Background: @XpBarBg;
Value: 0.0;
Direction: End;
}
Label #XpValue {
Style: (FontSize: 9, TextColor: #f0f0f0);
Text: "0 / 0";
Anchor: (Top: 2);
}
}
}
Label #BarTitle {
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
Text: "CAPACITÉS";
Anchor: (Left: 0, Bottom: 4);
}
Group #AbilityBarPanel {
LayoutMode: Top;
Padding: (Full: @AbilityPanelPad);
Background: #0d0f14(0.92);
Anchor: (Left: 0, Width: @AbilityPanelWidth);
Group #AbilitySlot1 {
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5);
Background: #2a3140;
LayoutMode: Top;
Padding: (Full: 4);
Label #Slot1Key {
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
Text: "[1]";
Anchor: (Bottom: 2);
}
Label #Slot1Name {
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
Text: "Capacité 1";
}
}
Group #AbilitySlot2 {
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize, Bottom: 5);
Background: #2a3140;
LayoutMode: Top;
Padding: (Full: 4);
Label #Slot2Key {
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
Text: "[2]";
Anchor: (Bottom: 2);
}
Label #Slot2Name {
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
Text: "Capacité 2";
}
}
Group #AbilitySlot3 {
Anchor: (Width: @AbilitySlotSize, Height: @AbilitySlotSize);
Background: #2a3140;
LayoutMode: Top;
Padding: (Full: 4);
Label #Slot3Key {
Style: (FontSize: 12, RenderBold: true, TextColor: #f5c842);
Text: "[3]";
Anchor: (Bottom: 2);
}
Label #Slot3Name {
Style: (FontSize: 10, TextColor: #ffffff, Wrap: true);
Text: "Capacité 3";
}
}
}
}
@@ -0,0 +1,29 @@
$C = "../Common.ui";
$C.@PageOverlay {}
$C.@Container {
Anchor: (Width: 600, Height: 700);
#Title {
Group {
$C.@Title {
@Text = %server.customUI.warpListPage.warps;
}
$C.@HeaderSearch {}
}
}
#Content {
LayoutMode: Left;
Group #ProfileList {
FlexWeight: 1;
LayoutMode: TopScrolling;
ScrollbarStyle: $C.@DefaultScrollbarStyle;
}
}
}
$C.@BackButton {}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Battleaxe_Mithril",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Crossbow_Ancient_Steel",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Daggers_Mithril",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Longsword_Mithril",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Shortbow_Cobalt",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
@@ -0,0 +1,20 @@
{
"Id": "Weapon_Staff_Mithril",
"Interactions": {
"Ability1": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 1 }
]
},
"Ability2": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 2 }
]
},
"Ability3": {
"Interactions": [
{ "Type": "mmorpg_cast_ability", "Slot": 3 }
]
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"Debug": false,
"DefaultLevel": 1,
"MaxPlayers": 500,
"BaseXpPerLevel": 100,
"KillExperience": 10,
"Database": {
"FileName": "mmorpg.db"
},
"Features": {
"Economy": true,
"Quests": false,
"Guilds": false
}
}
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS player_profiles (
uuid TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
experience INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -0,0 +1,63 @@
-- MMORPG progression schema: classes, powers, jobs, races, groups.
-- New columns on player_profiles. SQLite ignores duplicate ADD COLUMN guarded by
-- the migration runner (each migration is applied at most once via schema_migrations).
ALTER TABLE player_profiles ADD COLUMN class_id TEXT;
ALTER TABLE player_profiles ADD COLUMN powers TEXT NOT NULL DEFAULT '[]';
ALTER TABLE player_profiles ADD COLUMN jobs TEXT NOT NULL DEFAULT '[]';
ALTER TABLE player_profiles ADD COLUMN guild_id TEXT;
ALTER TABLE player_profiles ADD COLUMN group_id TEXT;
ALTER TABLE player_profiles ADD COLUMN is_connected INTEGER NOT NULL DEFAULT 0;
ALTER TABLE player_profiles ADD COLUMN total_time_play INTEGER NOT NULL DEFAULT 0;
ALTER TABLE player_profiles ADD COLUMN last_date_connected INTEGER NOT NULL DEFAULT 0;
ALTER TABLE player_profiles ADD COLUMN date_creation INTEGER NOT NULL DEFAULT 0;
ALTER TABLE player_profiles ADD COLUMN money INTEGER NOT NULL DEFAULT 0;
ALTER TABLE player_profiles ADD COLUMN race_id TEXT NOT NULL DEFAULT 'human';
-- Backfill creation date for profiles that predate this migration.
UPDATE player_profiles SET date_creation = created_at WHERE date_creation = 0;
-- Reference tables (definitions persisted for inspection / future editing).
CREATE TABLE IF NOT EXISTS classes (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
weapons TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS powers (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
passive INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS races (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
xp_multiplier REAL NOT NULL DEFAULT 1.0
);
-- Groups (parties): shared XP between members.
CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY,
owner_uuid TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS group_members (
group_id TEXT NOT NULL,
player_uuid TEXT NOT NULL,
joined_at INTEGER NOT NULL,
PRIMARY KEY (group_id, player_uuid),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_group_members_player ON group_members(player_uuid);
+18
View File
@@ -0,0 +1,18 @@
{
"Group": "com.disklexar",
"Name": "MMORPG",
"Version": "0.2.6",
"Description": "Serveur MMORPG Hytale — progression, persistance et systèmes sociaux",
"Authors": [
{
"Name": "Disklexar"
}
],
"Website": "https://github.com/disklexar/hytale-mmorpg",
"DisabledByDefault": false,
"IncludesAssetPack": true,
"Dependencies": {},
"OptionalDependencies": {},
"ServerVersion": "*",
"Main": "com.disklexar.mmorpg.MmorpgPlugin"
}