feat: Add leaderboard and stats UI for minigames
- Implemented MinigameLeaderboardUIService to manage the leaderboard UI. - Created MinigameLeaderboardUISystem to handle UI updates. - Added methods in MinigameStatsService and MinigameStatsRepository for fetching top players and distinct dimension values. - Introduced new commands for accessing minigame stats in the command language. - Added tests for minigame interactions and ensured proper decoding of interaction fields. - Refactored MinigameRuntimeEffect to utilize MinigameActionContext for better context handling.
This commit is contained in:
@@ -2,14 +2,16 @@
|
||||
|
||||
A Hytale-native minigame extension for server-side minigame definitions, runtime state, and trigger-volume logic.
|
||||
|
||||
The mod mirrors Hytale's own asset and trigger systems instead of maintaining a separate minigame-only registry layer. Minigames are `MinigameDefinition` assets, trigger logic is registered as real `TriggerEffect` and `TriggerCondition` codec entries, and runtime events are dispatched on Hytale's event bus.
|
||||
The mod mirrors Hytale's own asset, trigger, and interaction systems instead of maintaining a separate minigame-only registry layer. Minigames are `MinigameDefinition` assets, trigger logic is registered as real `TriggerEffect` and `TriggerCondition` codec entries, item/entity/NPC actions can use real `Interaction` entries, and runtime events are dispatched on Hytale's event bus.
|
||||
|
||||
## Features
|
||||
|
||||
- Asset-driven minigame definitions in `Server/Minigames/`
|
||||
- Hytale Asset Editor support through `MinigameDefinition.CODEC`
|
||||
- Hytale trigger-volume integration through native `TriggerEffect` and `TriggerCondition` types
|
||||
- Hytale interaction integration through native `Interaction` types for item/entity/NPC use actions
|
||||
- Asset-backed `MinigameId` picker fields in the trigger-volume editor
|
||||
- Asset-backed minigame trigger-volume templates in `Server/MinigameVolumeTemplates/`
|
||||
- Runtime lifecycle commands for creating, enabling, starting, stopping, and resetting minigames
|
||||
- Multi-map runtime support using Hytale trigger-volume tags
|
||||
- Player, team, score, lives, counter, status, and phase runtime state
|
||||
@@ -67,6 +69,17 @@ Use normal Hytale trigger volumes for the area shape and interaction point, then
|
||||
|
||||
For multi-map minigames, tag volumes with `MinigameId=<id>`, `MapId=<map>`, optional `VariantId=<variant>`, and `MapRole=MainArena` on the primary arena volume. Team spawn volumes use `SpawnRole=TeamSpawn` and `TeamId=<team>`. `MapSelectionMode` controls whether a queued match uses votes or random selection.
|
||||
|
||||
To bootstrap common logic, spawn editable template volumes at your position:
|
||||
|
||||
```text
|
||||
/triggervolume minigametemplates
|
||||
/triggervolume minigametemplate core_lifecycle --MinigameId=My_Minigame --MapId=Arena01
|
||||
/triggervolume minigametemplate team_arena --MinigameId=Battle --MapId=Castle --VariantId=Night
|
||||
```
|
||||
|
||||
The UI remembers each builder's minigame/map variables. Both paths create normal Hytale
|
||||
trigger volumes with template tags replaced by the provided IDs.
|
||||
|
||||
Available condition types:
|
||||
|
||||
- `MinigamePhase`
|
||||
@@ -85,26 +98,30 @@ Available condition types:
|
||||
Available effect types:
|
||||
|
||||
- `StartMinigame`
|
||||
- `EndMinigame`
|
||||
- `ResetMinigame`
|
||||
- `SetMinigamePhase`
|
||||
- `ResetScores`
|
||||
- `ModifyScore`
|
||||
- `ModifyCounter`
|
||||
- `ModifyLives`
|
||||
- `SetPlayerStatus`
|
||||
- `SetPlayerLoadout`
|
||||
- `JoinMinigameQueue`
|
||||
- `LeaveMinigameQueue`
|
||||
- `VoteForMap`
|
||||
- `StartQueuedMinigame`
|
||||
- `SpawnItem`
|
||||
- `AwardKillScore`
|
||||
- `EndMinigame`
|
||||
- `ResetMinigame`
|
||||
- `SetMinigamePhase`
|
||||
- `ResetScores`
|
||||
- `ModifyPlayerScore`
|
||||
- `ModifyTeamScore`
|
||||
- `ModifyCounter`
|
||||
- `ModifyPlayerLives`
|
||||
- `SetPlayerStatus`
|
||||
- `SpawnNpc`
|
||||
- `MountPlayer`
|
||||
- `DismountPlayer`
|
||||
- `SetRound`
|
||||
- `StartTimer`
|
||||
- `StopTimer`
|
||||
- `SetSpawnPoint`
|
||||
- `RespawnPlayer`
|
||||
- `RespawnAllPlayers`
|
||||
- `SwapTeams`
|
||||
- `SendMinigameMessage`
|
||||
- `AssignTeam`
|
||||
- `SaveInventory`
|
||||
@@ -112,17 +129,122 @@ Available effect types:
|
||||
- `ClearInventory`
|
||||
- `SetObjectiveStatus`
|
||||
- `ModifyObjectiveProgress`
|
||||
- `AwardKillScore`
|
||||
- `PlaceBlocks`
|
||||
- `OpenMinigameQueueUI`
|
||||
- `StartPlayerTimer`
|
||||
- `StopPlayerTimer`
|
||||
|
||||
Fields named `MinigameId` use an editor dropdown backed by registered `MinigameDefinition` assets.
|
||||
|
||||
## Interaction Logic
|
||||
|
||||
Many minigame effects also have Hytale interaction counterparts. Interaction type IDs use the `Minigame` prefix plus the trigger effect ID, for example `MinigameJoinMinigameQueue`, `MinigameModifyScore`, and `MinigameOpenMinigameQueueUI`.
|
||||
|
||||
Interaction-backed minigame actions use Hytale root interaction cooldown and chaining fields instead of trigger-volume `Event`, `Interval`, and `Delay`. Common routing fields are `MinigameId`, optional `MapId`, optional `VariantId`, and optional `TeamId`.
|
||||
|
||||
Available interaction types:
|
||||
|
||||
- `MinigameStartMinigame`
|
||||
- `MinigameEndMinigame`
|
||||
- `MinigameResetMinigame`
|
||||
- `MinigameSetMinigamePhase`
|
||||
- `MinigameResetScores`
|
||||
- `MinigameModifyScore`
|
||||
- `MinigameModifyCounter`
|
||||
- `MinigameModifyLives`
|
||||
- `MinigameSetPlayerStatus`
|
||||
- `MinigameSetPlayerLoadout`
|
||||
- `MinigameJoinMinigameQueue`
|
||||
- `MinigameLeaveMinigameQueue`
|
||||
- `MinigameVoteForMap`
|
||||
- `MinigameStartQueuedMinigame`
|
||||
- `MinigameSetRound`
|
||||
- `MinigameStartTimer`
|
||||
- `MinigameStopTimer`
|
||||
- `MinigameSetSpawnPoint`
|
||||
- `MinigameRespawnPlayer`
|
||||
- `MinigameRespawnAllPlayers`
|
||||
- `MinigameSwapTeams`
|
||||
- `MinigameSendMinigameMessage`
|
||||
- `MinigameAssignTeam`
|
||||
- `MinigameSaveInventory`
|
||||
- `MinigameRestoreInventory`
|
||||
- `MinigameClearInventory`
|
||||
- `MinigameSetObjectiveStatus`
|
||||
- `MinigameModifyObjectiveProgress`
|
||||
- `MinigameOpenMinigameQueueUI`
|
||||
- `MinigameStartPlayerTimer`
|
||||
- `MinigameStopPlayerTimer`
|
||||
|
||||
Volume-only for now: `AwardKillScore`, `SpawnItem`, `SpawnNpc`, `MountPlayer`, `DismountPlayer`, and `PlaceBlocks`.
|
||||
|
||||
Inline item secondary-use example:
|
||||
|
||||
```json
|
||||
{
|
||||
"Interactions": {
|
||||
"Secondary": {
|
||||
"Interactions": [
|
||||
{
|
||||
"Type": "MinigameJoinMinigameQueue",
|
||||
"MinigameId": "Skywars"
|
||||
}
|
||||
],
|
||||
"Cooldown": {
|
||||
"Id": "join_skywars_queue",
|
||||
"Cooldown": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reusable assets:
|
||||
|
||||
`Server/Item/Interactions/join_skywars_queue.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"Type": "MinigameJoinMinigameQueue",
|
||||
"MinigameId": "Skywars"
|
||||
}
|
||||
```
|
||||
|
||||
`Server/Item/RootInteractions/join_skywars_queue_secondary.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"Interactions": [
|
||||
"join_skywars_queue"
|
||||
],
|
||||
"Cooldown": {
|
||||
"Id": "join_skywars_queue",
|
||||
"Cooldown": 1.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then reference the root interaction from an item:
|
||||
|
||||
```json
|
||||
{
|
||||
"Interactions": {
|
||||
"Secondary": "join_skywars_queue_secondary"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
core-minigames/
|
||||
|-- src/main/java/net/kewwbec/minigames/
|
||||
| |-- MinigameCorePlugin.java
|
||||
| |-- action/
|
||||
| |-- command/
|
||||
| |-- event/
|
||||
| |-- interaction/
|
||||
| |-- model/
|
||||
| |-- runtime/
|
||||
| |-- service/
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
||||
import com.hypixel.hytale.component.ComponentType;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import com.hypixel.hytale.server.core.io.ServerManager;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.asset.HytaleAssetStore;
|
||||
@@ -17,6 +18,7 @@ import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
||||
import net.kewwbec.minigames.command.MinigameCommand;
|
||||
import net.kewwbec.minigames.interaction.MinigameInteractions;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.model.MinigameVolumeTemplate;
|
||||
import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService;
|
||||
@@ -38,10 +40,12 @@ import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
||||
import net.kewwbec.minigames.system.MinigameConnectionListener;
|
||||
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
||||
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
|
||||
import net.kewwbec.minigames.system.MinigameLeaderboardUISystem;
|
||||
import net.kewwbec.minigames.system.MinigamePregameSystem;
|
||||
import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems;
|
||||
import net.kewwbec.minigames.ui.MinigameHudService;
|
||||
import net.kewwbec.minigames.ui.MinigameHudSystem;
|
||||
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
|
||||
import net.kewwbec.minigames.ui.MinigameMenuService;
|
||||
import net.kewwbec.minigames.ui.MinigameQueueUIService;
|
||||
@@ -122,12 +126,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
private static MinigameCorePlugin instance;
|
||||
private MinigameServices services;
|
||||
private MinigameQueueUIService queueUIService;
|
||||
private MinigameLeaderboardUIService leaderboardUIService;
|
||||
private MinigameTemplateBuilderUIService templateBuilderUIService;
|
||||
private StatsDatabase statsDatabase;
|
||||
private PacketFilter duplicatePacketFilter;
|
||||
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
||||
private final java.util.List<String> ownEffectTypeIds = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> ownConditionTypeIds = new java.util.ArrayList<>();
|
||||
private final java.util.List<String> ownInteractionTypeIds = new java.util.ArrayList<>();
|
||||
|
||||
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
||||
super(init);
|
||||
@@ -144,6 +150,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
this.duplicatePacketFilter = duplicateSystem.register();
|
||||
registerTriggerVolumeEffectTypes();
|
||||
registerTriggerVolumeConditionTypes();
|
||||
registerMinigameInteractionTypes();
|
||||
|
||||
AssetRegistry.register(
|
||||
HytaleAssetStore.builder(MinigameDefinition.class, new DefaultAssetMap<String, MinigameDefinition>())
|
||||
@@ -190,6 +197,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
this.queueUIService = new MinigameQueueUIService(services);
|
||||
this.queueUIService.setPlayerAdapter(adapters);
|
||||
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
|
||||
this.leaderboardUIService = new MinigameLeaderboardUIService(services);
|
||||
getEntityStoreRegistry().registerSystem(new MinigameLeaderboardUISystem(leaderboardUIService));
|
||||
this.templateBuilderUIService =
|
||||
new MinigameTemplateBuilderUIService(new MinigameTemplateBuilderDataStore(getDataDirectory()));
|
||||
this.templateBuilderUIService.setPlayerAdapter(adapters);
|
||||
@@ -206,10 +215,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
|
||||
registerTriggerVolumeAssetSources();
|
||||
|
||||
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
|
||||
this.getCommandRegistry().registerCommand(new MinigameCommand(services, leaderboardUIService));
|
||||
injectTriggerVolumeCloneCommand();
|
||||
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
|
||||
+ " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects.");
|
||||
+ " trigger conditions, " + registeredTriggerEffectCount()
|
||||
+ " trigger effects, and " + registeredInteractionCount() + " interactions.");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,6 +259,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
duplicatePacketFilter = null;
|
||||
}
|
||||
services = null;
|
||||
queueUIService = null;
|
||||
leaderboardUIService = null;
|
||||
templateBuilderUIService = null;
|
||||
instance = null;
|
||||
}
|
||||
|
||||
@@ -262,6 +275,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
return instance != null ? instance.queueUIService : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MinigameLeaderboardUIService getLeaderboardUIService() {
|
||||
return instance != null ? instance.leaderboardUIService : null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MinigameTemplateBuilderUIService getTemplateBuilderUIService() {
|
||||
return instance != null ? instance.templateBuilderUIService : null;
|
||||
@@ -376,6 +394,51 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
private void registerMinigameInteractionTypes() {
|
||||
registerMinigameInteractionType(MinigameInteractions.StartMinigame.TYPE_ID, MinigameInteractions.StartMinigame.class, MinigameInteractions.StartMinigame.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.EndMinigame.TYPE_ID, MinigameInteractions.EndMinigame.class, MinigameInteractions.EndMinigame.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ResetMinigame.TYPE_ID, MinigameInteractions.ResetMinigame.class, MinigameInteractions.ResetMinigame.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetMinigamePhase.TYPE_ID, MinigameInteractions.SetMinigamePhase.class, MinigameInteractions.SetMinigamePhase.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ResetScores.TYPE_ID, MinigameInteractions.ResetScores.class, MinigameInteractions.ResetScores.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ModifyScore.TYPE_ID, MinigameInteractions.ModifyScore.class, MinigameInteractions.ModifyScore.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ModifyCounter.TYPE_ID, MinigameInteractions.ModifyCounter.class, MinigameInteractions.ModifyCounter.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ModifyLives.TYPE_ID, MinigameInteractions.ModifyLives.class, MinigameInteractions.ModifyLives.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetPlayerStatus.TYPE_ID, MinigameInteractions.SetPlayerStatus.class, MinigameInteractions.SetPlayerStatus.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetPlayerLoadout.TYPE_ID, MinigameInteractions.SetPlayerLoadout.class, MinigameInteractions.SetPlayerLoadout.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.JoinMinigameQueue.TYPE_ID, MinigameInteractions.JoinMinigameQueue.class, MinigameInteractions.JoinMinigameQueue.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.LeaveMinigameQueue.TYPE_ID, MinigameInteractions.LeaveMinigameQueue.class, MinigameInteractions.LeaveMinigameQueue.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.VoteForMap.TYPE_ID, MinigameInteractions.VoteForMap.class, MinigameInteractions.VoteForMap.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.StartQueuedMinigame.TYPE_ID, MinigameInteractions.StartQueuedMinigame.class, MinigameInteractions.StartQueuedMinigame.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetRound.TYPE_ID, MinigameInteractions.SetRound.class, MinigameInteractions.SetRound.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.StartTimer.TYPE_ID, MinigameInteractions.StartTimer.class, MinigameInteractions.StartTimer.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.StopTimer.TYPE_ID, MinigameInteractions.StopTimer.class, MinigameInteractions.StopTimer.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetSpawnPoint.TYPE_ID, MinigameInteractions.SetSpawnPoint.class, MinigameInteractions.SetSpawnPoint.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.RespawnPlayer.TYPE_ID, MinigameInteractions.RespawnPlayer.class, MinigameInteractions.RespawnPlayer.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.RespawnAllPlayers.TYPE_ID, MinigameInteractions.RespawnAllPlayers.class, MinigameInteractions.RespawnAllPlayers.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SwapTeams.TYPE_ID, MinigameInteractions.SwapTeams.class, MinigameInteractions.SwapTeams.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SendMinigameMessage.TYPE_ID, MinigameInteractions.SendMinigameMessage.class, MinigameInteractions.SendMinigameMessage.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.AssignTeam.TYPE_ID, MinigameInteractions.AssignTeam.class, MinigameInteractions.AssignTeam.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SaveInventory.TYPE_ID, MinigameInteractions.SaveInventory.class, MinigameInteractions.SaveInventory.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.RestoreInventory.TYPE_ID, MinigameInteractions.RestoreInventory.class, MinigameInteractions.RestoreInventory.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ClearInventory.TYPE_ID, MinigameInteractions.ClearInventory.class, MinigameInteractions.ClearInventory.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.SetObjectiveStatus.TYPE_ID, MinigameInteractions.SetObjectiveStatus.class, MinigameInteractions.SetObjectiveStatus.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.ModifyObjectiveProgress.TYPE_ID, MinigameInteractions.ModifyObjectiveProgress.class, MinigameInteractions.ModifyObjectiveProgress.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.OpenMinigameQueueUI.TYPE_ID, MinigameInteractions.OpenMinigameQueueUI.class, MinigameInteractions.OpenMinigameQueueUI.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.StartPlayerTimer.TYPE_ID, MinigameInteractions.StartPlayerTimer.class, MinigameInteractions.StartPlayerTimer.CODEC);
|
||||
registerMinigameInteractionType(MinigameInteractions.StopPlayerTimer.TYPE_ID, MinigameInteractions.StopPlayerTimer.class, MinigameInteractions.StopPlayerTimer.CODEC);
|
||||
}
|
||||
|
||||
private <T extends Interaction> void registerMinigameInteractionType(
|
||||
@Nonnull String typeId,
|
||||
@Nonnull Class<T> clazz,
|
||||
@Nonnull BuilderCodec<T> codec
|
||||
) {
|
||||
ownInteractionTypeIds.add(typeId);
|
||||
if (Interaction.CODEC.getCodecFor(typeId) == null) {
|
||||
getCodecRegistry(Interaction.CODEC).register(typeId, clazz, codec);
|
||||
}
|
||||
}
|
||||
|
||||
private void registerTriggerVolumeConditionTypes() {
|
||||
registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC);
|
||||
registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC);
|
||||
@@ -486,4 +549,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
||||
var registered = TriggerCondition.CODEC.getRegisteredIds();
|
||||
return (int) ownConditionTypeIds.stream().filter(registered::contains).count();
|
||||
}
|
||||
|
||||
private int registeredInteractionCount() {
|
||||
var registered = Interaction.CODEC.getRegisteredIds();
|
||||
return (int) ownInteractionTypeIds.stream().filter(registered::contains).count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package net.kewwbec.minigames.action;
|
||||
|
||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Map;
|
||||
|
||||
public interface MinigameActionContext {
|
||||
@Nonnull
|
||||
Ref<EntityStore> entityRef();
|
||||
|
||||
@Nonnull
|
||||
Store<EntityStore> store();
|
||||
|
||||
@Nullable
|
||||
String tagValue(@Nonnull String key);
|
||||
|
||||
@Nonnull
|
||||
default MinigameMapCandidate candidate(@Nonnull String fallbackMinigameId) {
|
||||
String minigameId = clean(tagValue(MinigameMapDiscoveryService.TAG_MINIGAME_ID));
|
||||
if (minigameId.isBlank()) {
|
||||
minigameId = clean(fallbackMinigameId);
|
||||
}
|
||||
return new MinigameMapCandidate(
|
||||
minigameId,
|
||||
clean(tagValue(MinigameMapDiscoveryService.TAG_MAP_ID)),
|
||||
clean(tagValue(MinigameMapDiscoveryService.TAG_VARIANT_ID)),
|
||||
1,
|
||||
MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(clean(tagValue(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
|
||||
);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
static MinigameActionContext fromTrigger(@Nonnull TriggerContext context, @Nullable String configuredMinigameId) {
|
||||
return new TriggerBacked(context, configuredMinigameId);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
static MinigameActionContext fromInteraction(
|
||||
@Nonnull Ref<EntityStore> entityRef,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nullable String minigameId,
|
||||
@Nullable String mapId,
|
||||
@Nullable String variantId,
|
||||
@Nullable String teamId
|
||||
) {
|
||||
return new InteractionBacked(entityRef, store, minigameId, mapId, variantId, teamId);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
static String clean(@Nullable String value) {
|
||||
return value != null && !value.isBlank() ? value.trim() : "";
|
||||
}
|
||||
|
||||
final class TriggerBacked implements MinigameActionContext {
|
||||
@Nonnull
|
||||
private final TriggerContext context;
|
||||
@Nullable
|
||||
private final String configuredMinigameId;
|
||||
|
||||
private TriggerBacked(@Nonnull TriggerContext context, @Nullable String configuredMinigameId) {
|
||||
this.context = context;
|
||||
this.configuredMinigameId = configuredMinigameId;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Ref<EntityStore> entityRef() {
|
||||
return context.getEntityRef();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Store<EntityStore> store() {
|
||||
return context.getStore();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String tagValue(@Nonnull String key) {
|
||||
if (MinigameMapDiscoveryService.TAG_MINIGAME_ID.equals(key)
|
||||
&& (context.getVolume() == null
|
||||
|| MinigameActionContext.clean(context.getVolume().getRawTags().get(key)).isBlank())) {
|
||||
return configuredMinigameId;
|
||||
}
|
||||
Map<String, String> tags = context.getVolume() != null ? context.getVolume().getRawTags() : Map.of();
|
||||
return tags.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
final class InteractionBacked implements MinigameActionContext {
|
||||
@Nonnull
|
||||
private final Ref<EntityStore> entityRef;
|
||||
@Nonnull
|
||||
private final Store<EntityStore> store;
|
||||
@Nullable
|
||||
private final String minigameId;
|
||||
@Nullable
|
||||
private final String mapId;
|
||||
@Nullable
|
||||
private final String variantId;
|
||||
@Nullable
|
||||
private final String teamId;
|
||||
|
||||
private InteractionBacked(
|
||||
@Nonnull Ref<EntityStore> entityRef,
|
||||
@Nonnull Store<EntityStore> store,
|
||||
@Nullable String minigameId,
|
||||
@Nullable String mapId,
|
||||
@Nullable String variantId,
|
||||
@Nullable String teamId
|
||||
) {
|
||||
this.entityRef = entityRef;
|
||||
this.store = store;
|
||||
this.minigameId = minigameId;
|
||||
this.mapId = mapId;
|
||||
this.variantId = variantId;
|
||||
this.teamId = teamId;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Ref<EntityStore> entityRef() {
|
||||
return entityRef;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Store<EntityStore> store() {
|
||||
return store;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String tagValue(@Nonnull String key) {
|
||||
return switch (key) {
|
||||
case MinigameMapDiscoveryService.TAG_MINIGAME_ID -> minigameId;
|
||||
case MinigameMapDiscoveryService.TAG_MAP_ID -> mapId;
|
||||
case MinigameMapDiscoveryService.TAG_VARIANT_ID -> variantId;
|
||||
case MinigameMapDiscoveryService.TAG_TEAM_ID -> teamId;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package net.kewwbec.minigames.action;
|
||||
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.awt.Color;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class MinigameActionSupport {
|
||||
private MinigameActionSupport() {
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MinigameServices services() {
|
||||
return MinigameCorePlugin.getServices();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context, @Nullable String configuredMinigameId) {
|
||||
MinigameServices services = services();
|
||||
if (services == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var candidate = context.candidate(configuredMinigameId != null ? configuredMinigameId : "");
|
||||
String declaredId = !candidate.minigameId().isBlank()
|
||||
? candidate.minigameId()
|
||||
: (configuredMinigameId != null ? configuredMinigameId : "");
|
||||
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||
if (playerRuntime.isPresent()
|
||||
&& (declaredId.isBlank() || playerRuntime.get().minigameId().equals(declaredId))) {
|
||||
return playerRuntime;
|
||||
}
|
||||
}
|
||||
|
||||
if (declaredId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static String resolvedMinigameId(@Nonnull MinigameActionContext context, @Nullable String configuredMinigameId) {
|
||||
var services = services();
|
||||
if (services != null) {
|
||||
var candidate = context.candidate(configuredMinigameId != null ? configuredMinigameId : "");
|
||||
if (!candidate.minigameId().isBlank()) {
|
||||
return candidate.minigameId();
|
||||
}
|
||||
if (configuredMinigameId == null || configuredMinigameId.isBlank()) {
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
var playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||
if (playerRuntime.isPresent()) {
|
||||
return playerRuntime.get().minigameId();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return configuredMinigameId != null ? configuredMinigameId : "";
|
||||
}
|
||||
|
||||
public static long debugMessage(
|
||||
@Nonnull MinigameActionContext context,
|
||||
@Nullable String configuredMinigameId,
|
||||
@Nonnull String actionType,
|
||||
@Nonnull String details,
|
||||
long lastDebugMs
|
||||
) {
|
||||
String id = resolvedMinigameId(context, configuredMinigameId);
|
||||
if (id.isBlank()) return lastDebugMs;
|
||||
var def = MinigameDefinition.getAssetMap().getAsset(id);
|
||||
if (def == null || !def.isDebug()) return lastDebugMs;
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDebugMs < 5000L) return lastDebugMs;
|
||||
PlayerRef playerRef = context.store().getComponent(context.entityRef(), PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
playerRef.sendMessage(
|
||||
Message.translation("minigames.debug.effect")
|
||||
.param("type", actionType)
|
||||
.param("details", details)
|
||||
.color(Color.CYAN)
|
||||
);
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public static String displayName(@Nonnull String playerId) {
|
||||
MinigameServices services = services();
|
||||
if (services != null) {
|
||||
String name = services.adapters().getDisplayName(playerId);
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return playerId;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) {
|
||||
return configuredPlayerId;
|
||||
}
|
||||
|
||||
PlayerRef playerRef = context.store().getComponent(context.entityRef(), PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
return playerRef.getUuid().toString();
|
||||
}
|
||||
|
||||
UUIDComponent uuidComponent = context.store().getComponent(context.entityRef(), UUIDComponent.getComponentType());
|
||||
return uuidComponent != null ? uuidComponent.getUuid().toString() : null;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package net.kewwbec.minigames.command;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||
import net.kewwbec.minigames.command.sub.*;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||
|
||||
public final class MinigameCommand extends AbstractCommandCollection {
|
||||
public MinigameCommand(MinigameServices services) {
|
||||
public MinigameCommand(MinigameServices services, MinigameLeaderboardUIService leaderboardUIService) {
|
||||
super("minigame", "minigames.command.root.description");
|
||||
this.setPermissionGroups("hytale:WorldEditor");
|
||||
|
||||
@@ -25,5 +26,6 @@ public final class MinigameCommand extends AbstractCommandCollection {
|
||||
addSubCommand(new MinigameJoinCommand(services));
|
||||
addSubCommand(new MinigameLeaveCommand(services));
|
||||
addSubCommand(new MinigameSpectateCommand(services));
|
||||
addSubCommand(new MinigameStatsCommand(services, leaderboardUIService));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package net.kewwbec.minigames.command.sub;
|
||||
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public final class MinigameStatsCommand extends CommandBase {
|
||||
private final MinigameServices services;
|
||||
private final MinigameLeaderboardUIService uiService;
|
||||
private final OptionalArg<String> idArg;
|
||||
|
||||
public MinigameStatsCommand(MinigameServices services, MinigameLeaderboardUIService uiService) {
|
||||
super("stats", "minigames.command.spec.stats.description");
|
||||
this.services = services;
|
||||
this.uiService = uiService;
|
||||
this.idArg = withOptionalArg("id", "minigames.command.spec.stats.arg.id", ArgTypes.STRING);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||
PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null;
|
||||
if (player == null) {
|
||||
ctx.sendMessage(Message.raw("[Minigames] The stats UI must be opened by a player."));
|
||||
return;
|
||||
}
|
||||
|
||||
String id = ctx.provided(idArg) ? ctx.get(idArg) : "";
|
||||
if (!id.isBlank() && services.minigames().definition(id).isEmpty()) {
|
||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
||||
return;
|
||||
}
|
||||
|
||||
uiService.requestOpen(player, id);
|
||||
ctx.sendMessage(Message.raw("[Minigames] Opening stats UI."));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
package net.kewwbec.minigames.interaction;
|
||||
|
||||
import com.hypixel.hytale.codec.Codec;
|
||||
import com.hypixel.hytale.codec.KeyedCodec;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import com.hypixel.hytale.component.CommandBuffer;
|
||||
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.modules.interaction.interaction.CooldownHandler;
|
||||
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.action.MinigameActionContext;
|
||||
import net.kewwbec.minigames.action.MinigameActionSupport;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class MinigameRuntimeInteraction extends SimpleInstantInteraction {
|
||||
@Nonnull
|
||||
public static final BuilderCodec<MinigameRuntimeInteraction> MINIGAME_INTERACTION_BASE_CODEC =
|
||||
BuilderCodec.abstractBuilder(MinigameRuntimeInteraction.class, SimpleInstantInteraction.CODEC)
|
||||
.<String>appendInherited(
|
||||
new KeyedCodec<>("MinigameId", Codec.STRING, false),
|
||||
(interaction, value) -> interaction.minigameId = value,
|
||||
interaction -> interaction.minigameId,
|
||||
(interaction, parent) -> interaction.minigameId = parent.minigameId
|
||||
)
|
||||
.add()
|
||||
.<String>appendInherited(
|
||||
new KeyedCodec<>("MapId", Codec.STRING, false),
|
||||
(interaction, value) -> interaction.mapId = value,
|
||||
interaction -> interaction.mapId,
|
||||
(interaction, parent) -> interaction.mapId = parent.mapId
|
||||
)
|
||||
.add()
|
||||
.<String>appendInherited(
|
||||
new KeyedCodec<>("VariantId", Codec.STRING, false),
|
||||
(interaction, value) -> interaction.variantId = value,
|
||||
interaction -> interaction.variantId,
|
||||
(interaction, parent) -> interaction.variantId = parent.variantId
|
||||
)
|
||||
.add()
|
||||
.<String>appendInherited(
|
||||
new KeyedCodec<>("TeamId", Codec.STRING, false),
|
||||
(interaction, value) -> interaction.teamId = value,
|
||||
interaction -> interaction.teamId,
|
||||
(interaction, parent) -> interaction.teamId = parent.teamId
|
||||
)
|
||||
.add()
|
||||
.build();
|
||||
|
||||
@Nullable
|
||||
protected String minigameId;
|
||||
@Nullable
|
||||
protected String mapId;
|
||||
@Nullable
|
||||
protected String variantId;
|
||||
@Nullable
|
||||
protected String teamId;
|
||||
|
||||
private volatile long lastDebugMs = 0L;
|
||||
|
||||
protected MinigameRuntimeInteraction() {
|
||||
}
|
||||
|
||||
public MinigameRuntimeInteraction(@Nonnull String id) {
|
||||
super(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void firstRun(
|
||||
@Nonnull InteractionType interactionType,
|
||||
@Nonnull InteractionContext interactionContext,
|
||||
@Nonnull CooldownHandler cooldownHandler
|
||||
) {
|
||||
MinigameActionContext context = actionContext(interactionContext);
|
||||
if (context == null) {
|
||||
interactionContext.getState().state = InteractionState.Failed;
|
||||
return;
|
||||
}
|
||||
execute(context);
|
||||
}
|
||||
|
||||
protected abstract void execute(@Nonnull MinigameActionContext context);
|
||||
|
||||
@Nullable
|
||||
protected MinigameActionContext actionContext(@Nonnull InteractionContext context) {
|
||||
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
|
||||
Ref<EntityStore> entityRef = context.getEntity();
|
||||
if (commandBuffer == null || entityRef == null || !entityRef.isValid() || commandBuffer.getExternalData() == null) {
|
||||
return null;
|
||||
}
|
||||
Store<EntityStore> store = commandBuffer.getExternalData().getStore();
|
||||
if (store == null) {
|
||||
return null;
|
||||
}
|
||||
return MinigameActionContext.fromInteraction(entityRef, store, minigameId, mapId, variantId, teamId);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
|
||||
return MinigameActionSupport.runtime(context, minigameId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static MinigameServices services() {
|
||||
return MinigameActionSupport.services();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
protected String resolvedMinigameId(@Nonnull MinigameActionContext context) {
|
||||
return MinigameActionSupport.resolvedMinigameId(context, minigameId);
|
||||
}
|
||||
|
||||
protected void debugMessage(@Nonnull MinigameActionContext context, @Nonnull String actionType, @Nonnull String details) {
|
||||
lastDebugMs = MinigameActionSupport.debugMessage(context, minigameId, actionType, details, lastDebugMs);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
protected static String displayName(@Nonnull String playerId) {
|
||||
return MinigameActionSupport.displayName(playerId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||
return MinigameActionSupport.resolvePlayerId(context, configuredPlayerId);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,29 @@ public final class MinigameStatsService {
|
||||
return sessions.get(runtimeId);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public List<MinigameStatsRepository.BreakdownRow> topPlayers(
|
||||
@Nonnull String minigameId,
|
||||
@Nullable String mapId,
|
||||
@Nullable String variantId,
|
||||
@Nullable String teamId,
|
||||
@Nonnull String orderColumn,
|
||||
int limit
|
||||
) {
|
||||
if (repository == null) {
|
||||
return List.of();
|
||||
}
|
||||
return repository.topPlayers(minigameId, mapId, variantId, teamId, orderColumn, limit);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
public List<String> distinctDimensionValues(@Nonnull String minigameId, @Nonnull String dimension) {
|
||||
if (repository == null) {
|
||||
return List.of();
|
||||
}
|
||||
return repository.distinctDimensionValues(minigameId, dimension);
|
||||
}
|
||||
|
||||
// --- recording (open-ended; add new stats with no schema change) -----------------------------
|
||||
|
||||
public void incrementStat(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, long amount) {
|
||||
|
||||
@@ -182,6 +182,97 @@ public final class MinigameStatsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/** Top players for a minigame, optionally narrowed by finished-game map, variant, and team. */
|
||||
@Nonnull
|
||||
public List<BreakdownRow> topPlayers(
|
||||
@Nonnull String minigameId,
|
||||
@Nullable String mapId,
|
||||
@Nullable String variantId,
|
||||
@Nullable String teamId,
|
||||
@Nonnull String orderColumn,
|
||||
int limit
|
||||
) {
|
||||
String column = sanitizeBreakdownOrderColumn(orderColumn);
|
||||
String sql = """
|
||||
SELECT
|
||||
p.player_uuid,
|
||||
COALESCE(MAX(p.player_username), p.player_uuid) AS player_username,
|
||||
COUNT(*) AS games_played,
|
||||
SUM(p.win) AS wins,
|
||||
COUNT(*) - SUM(p.win) AS losses,
|
||||
SUM(p.kills) AS kills,
|
||||
SUM(p.deaths) AS deaths,
|
||||
SUM(p.assists) AS assists,
|
||||
SUM(p.score) AS score_total,
|
||||
MIN(p.placement) AS best_placement,
|
||||
SUM(p.playtime_ms) AS playtime_ms,
|
||||
MAX(g.ended_at) AS last_played_at
|
||||
FROM minigame_game_participants p
|
||||
JOIN minigame_games g ON g.id = p.game_id
|
||||
WHERE p.minigame_id = ?
|
||||
AND (? = '' OR COALESCE(g.map_id, '') = ?)
|
||||
AND (? = '' OR COALESCE(g.variant_id, '') = ?)
|
||||
AND (? = '' OR COALESCE(p.team_id, '') = ?)
|
||||
GROUP BY p.player_uuid
|
||||
ORDER BY %s DESC
|
||||
LIMIT ?
|
||||
""".formatted(column);
|
||||
return db.withConnection(c -> {
|
||||
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, minigameId);
|
||||
bindBlankFilter(p, 2, mapId);
|
||||
bindBlankFilter(p, 4, variantId);
|
||||
bindBlankFilter(p, 6, teamId);
|
||||
p.setInt(8, Math.max(1, limit));
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
List<BreakdownRow> rows = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
rows.add(readBreakdown(rs));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Distinct values observed in persisted games for one drilldown dimension. */
|
||||
@Nonnull
|
||||
public List<String> distinctDimensionValues(@Nonnull String minigameId, @Nonnull String dimension) {
|
||||
String sql = switch (dimension) {
|
||||
case "map" -> """
|
||||
SELECT DISTINCT COALESCE(map_id, '') AS value
|
||||
FROM minigame_games
|
||||
WHERE minigame_id = ? AND COALESCE(map_id, '') <> ''
|
||||
ORDER BY value
|
||||
""";
|
||||
case "variant" -> """
|
||||
SELECT DISTINCT COALESCE(variant_id, '') AS value
|
||||
FROM minigame_games
|
||||
WHERE minigame_id = ? AND COALESCE(variant_id, '') <> ''
|
||||
ORDER BY value
|
||||
""";
|
||||
case "team" -> """
|
||||
SELECT DISTINCT COALESCE(team_id, '') AS value
|
||||
FROM minigame_game_participants
|
||||
WHERE minigame_id = ? AND COALESCE(team_id, '') <> ''
|
||||
ORDER BY value
|
||||
""";
|
||||
default -> throw new IllegalArgumentException("Unsupported stats dimension: " + dimension);
|
||||
};
|
||||
return db.withConnection(c -> {
|
||||
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||
p.setString(1, minigameId);
|
||||
try (ResultSet rs = p.executeQuery()) {
|
||||
List<String> values = new ArrayList<>();
|
||||
while (rs.next()) {
|
||||
values.add(rs.getString("value"));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static AggregateRow readAggregate(ResultSet rs) throws SQLException {
|
||||
int bestPlacement = rs.getInt("best_placement");
|
||||
return new AggregateRow(
|
||||
@@ -201,6 +292,30 @@ public final class MinigameStatsRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private static BreakdownRow readBreakdown(ResultSet rs) throws SQLException {
|
||||
int bestPlacement = rs.getInt("best_placement");
|
||||
return new BreakdownRow(
|
||||
rs.getString("player_uuid"),
|
||||
rs.getString("player_username"),
|
||||
rs.getLong("games_played"),
|
||||
rs.getLong("wins"),
|
||||
rs.getLong("losses"),
|
||||
rs.getLong("kills"),
|
||||
rs.getLong("deaths"),
|
||||
rs.getLong("assists"),
|
||||
rs.getLong("score_total"),
|
||||
rs.wasNull() ? null : bestPlacement,
|
||||
rs.getLong("playtime_ms"),
|
||||
rs.getString("last_played_at")
|
||||
);
|
||||
}
|
||||
|
||||
private static void bindBlankFilter(PreparedStatement p, int firstIndex, @Nullable String value) throws SQLException {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
p.setString(firstIndex, normalized);
|
||||
p.setString(firstIndex + 1, normalized);
|
||||
}
|
||||
|
||||
/** Whitelist guard: only the fixed aggregate columns may be sorted on (avoids SQL injection). */
|
||||
private static String sanitizeOrderColumn(String requested) {
|
||||
return switch (requested) {
|
||||
@@ -209,6 +324,13 @@ public final class MinigameStatsRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private static String sanitizeBreakdownOrderColumn(String requested) {
|
||||
return switch (requested) {
|
||||
case "games_played", "wins", "losses", "kills", "deaths", "assists", "score_total", "playtime_ms" -> requested;
|
||||
default -> "score_total";
|
||||
};
|
||||
}
|
||||
|
||||
/** Read view of a lifetime aggregate row. */
|
||||
public record AggregateRow(
|
||||
String playerUuid,
|
||||
@@ -226,4 +348,21 @@ public final class MinigameStatsRepository {
|
||||
@Nullable String lastPlayedAt
|
||||
) {
|
||||
}
|
||||
|
||||
/** Read view for a filtered player leaderboard assembled from participant history. */
|
||||
public record BreakdownRow(
|
||||
String playerUuid,
|
||||
@Nullable String playerUsername,
|
||||
long gamesPlayed,
|
||||
long wins,
|
||||
long losses,
|
||||
long kills,
|
||||
long deaths,
|
||||
long assists,
|
||||
long scoreTotal,
|
||||
@Nullable Integer bestPlacement,
|
||||
long playtimeMs,
|
||||
@Nullable String lastPlayedAt
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.kewwbec.minigames.system;
|
||||
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.component.system.tick.TickingSystem;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||
|
||||
public final class MinigameLeaderboardUISystem extends TickingSystem<EntityStore> {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
|
||||
private final MinigameLeaderboardUIService service;
|
||||
|
||||
public MinigameLeaderboardUISystem(MinigameLeaderboardUIService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
||||
try {
|
||||
service.openQueued(store);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.atWarning().log("Leaderboard UI tick failed: %s: %s",
|
||||
e.getClass().getSimpleName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
package net.kewwbec.minigames.ui;
|
||||
|
||||
import au.ellie.hyui.builders.ButtonBuilder;
|
||||
import au.ellie.hyui.builders.ContainerBuilder;
|
||||
import au.ellie.hyui.builders.GroupBuilder;
|
||||
import au.ellie.hyui.builders.HyUIAnchor;
|
||||
import au.ellie.hyui.builders.HyUIPadding;
|
||||
import au.ellie.hyui.builders.HyUIPatchStyle;
|
||||
import au.ellie.hyui.builders.HyUIStyle;
|
||||
import au.ellie.hyui.builders.LabelBuilder;
|
||||
import au.ellie.hyui.builders.PageBuilder;
|
||||
import au.ellie.hyui.builders.UIElementBuilder;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||
import com.hypixel.hytale.component.Ref;
|
||||
import com.hypixel.hytale.component.Store;
|
||||
import com.hypixel.hytale.logger.HytaleLogger;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
import net.kewwbec.minigames.stats.db.MinigameStatsRepository;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class MinigameLeaderboardUIService {
|
||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||
private static final long TTL_MS = 10_000;
|
||||
private static final int SECTION_INSET = 22;
|
||||
private static final int LIMIT = 10;
|
||||
|
||||
private final MinigameServices services;
|
||||
private final ConcurrentHashMap<UUID, PendingOpen> pendingOpens = new ConcurrentHashMap<>();
|
||||
|
||||
public MinigameLeaderboardUIService(MinigameServices services) {
|
||||
this.services = services;
|
||||
}
|
||||
|
||||
public void requestOpen(PlayerRef player, @Nullable String minigameId) {
|
||||
pendingOpens.put(player.getUuid(), new PendingOpen(
|
||||
player.getUuid(),
|
||||
minigameId == null ? "" : minigameId.trim(),
|
||||
System.currentTimeMillis()
|
||||
));
|
||||
}
|
||||
|
||||
public void openQueued(Store<EntityStore> store) {
|
||||
long now = System.currentTimeMillis();
|
||||
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||
TriggerVolumeManager manager = null;
|
||||
if (tvPlugin != null) {
|
||||
manager = store.getResource(tvPlugin.getManagerResourceType());
|
||||
}
|
||||
|
||||
for (PendingOpen pending : pendingOpens.values()) {
|
||||
if (now - pending.createdAtMillis() > TTL_MS) {
|
||||
pendingOpens.remove(pending.uuid(), pending);
|
||||
continue;
|
||||
}
|
||||
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.uuid());
|
||||
if (ref == null || !ref.isValid()) continue;
|
||||
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||
if (player == null) continue;
|
||||
if (pendingOpens.remove(pending.uuid(), pending)) {
|
||||
try {
|
||||
open(player, pending.minigameId(), "", "", "", "score_total", store, manager);
|
||||
} catch (Throwable e) {
|
||||
LOGGER.atWarning().log("Leaderboard UI open failed player=%s: %s: %s",
|
||||
player.getUuid(), e.getClass().getSimpleName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void open(
|
||||
PlayerRef player,
|
||||
String selectedMinigameId,
|
||||
String mapId,
|
||||
String variantId,
|
||||
String teamId,
|
||||
String sort,
|
||||
Store<EntityStore> store,
|
||||
@Nullable TriggerVolumeManager manager
|
||||
) {
|
||||
List<MinigameDefinition> defs = services.minigames().definitions().stream()
|
||||
.filter(MinigameDefinition::isEnabled)
|
||||
.sorted(java.util.Comparator.comparing(MinigameDefinition::getId))
|
||||
.toList();
|
||||
|
||||
String minigameId = selectedMinigameId;
|
||||
if (minigameId.isBlank() && !defs.isEmpty()) {
|
||||
minigameId = defs.get(0).getId();
|
||||
}
|
||||
String finalMinigameId = minigameId;
|
||||
MinigameDefinition selected = defs.stream()
|
||||
.filter(def -> def.getId().equals(finalMinigameId))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
GroupBuilder gameList = surface("MINIGAMES", "Enabled definitions", 288, 474);
|
||||
if (defs.isEmpty()) {
|
||||
gameList.addChild(inset(caption("No enabled minigames are loaded.", 226), 288, 226, 24));
|
||||
} else {
|
||||
for (MinigameDefinition def : defs) {
|
||||
int maps = activeMapIds(manager, def.getId()).size();
|
||||
boolean active = def.getId().equals(minigameId);
|
||||
gameList.addChild(spacer(288, 6));
|
||||
gameList.addChild(inset(
|
||||
ButtonBuilder.secondaryTextButton()
|
||||
.withText((active ? "> " : "") + def.getName() + " | " + maps + " maps")
|
||||
.withTooltipText(def.getId())
|
||||
.withAnchor(new HyUIAnchor().setWidth(226).setHeight(38))
|
||||
.onClick((ignored, ui) -> open(player, def.getId(), "", "", "", sort, store, manager)),
|
||||
288, 226, 38));
|
||||
}
|
||||
}
|
||||
|
||||
GroupBuilder detail = surface(
|
||||
selected == null ? "LEADERBOARD" : selected.getName(),
|
||||
subtitle(mapId, variantId, teamId, sort),
|
||||
600,
|
||||
474
|
||||
);
|
||||
|
||||
if (selected == null) {
|
||||
detail.addChild(inset(caption("Select a minigame to view stats.", 538), 600, 538, 24));
|
||||
} else {
|
||||
detail.addChild(filters(player, selected.getId(), mapId, variantId, teamId, sort, store, manager));
|
||||
detail.addChild(spacer(600, 8));
|
||||
detail.addChild(sortButtons(player, selected.getId(), mapId, variantId, teamId, sort, store, manager));
|
||||
detail.addChild(spacer(600, 8));
|
||||
|
||||
List<MinigameStatsRepository.BreakdownRow> rows = services.stats()
|
||||
.topPlayers(selected.getId(), mapId, variantId, teamId, sort, LIMIT);
|
||||
if (rows.isEmpty()) {
|
||||
detail.addChild(inset(caption("No finished-game stats match this selection yet.", 538), 600, 538, 24));
|
||||
} else {
|
||||
detail.addChild(tableHeader());
|
||||
int rank = 1;
|
||||
for (MinigameStatsRepository.BreakdownRow row : rows) {
|
||||
detail.addChild(row(rank++, row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContainerBuilder container = compactDecoratedContainer("Minigame Stats", 940, 560)
|
||||
.withPadding(HyUIPadding.symmetric(18, 14))
|
||||
.withLayoutMode("Top")
|
||||
.withBackground(patch("#101722"))
|
||||
.addContentChild(GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(904).setHeight(474))
|
||||
.addChild(gameList)
|
||||
.addChild(spacer(16, 474))
|
||||
.addChild(detail));
|
||||
|
||||
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
|
||||
}
|
||||
|
||||
private GroupBuilder filters(
|
||||
PlayerRef player,
|
||||
String minigameId,
|
||||
String mapId,
|
||||
String variantId,
|
||||
String teamId,
|
||||
String sort,
|
||||
Store<EntityStore> store,
|
||||
@Nullable TriggerVolumeManager manager
|
||||
) {
|
||||
GroupBuilder group = GroupBuilder.group()
|
||||
.withLayoutMode("Top")
|
||||
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(118));
|
||||
|
||||
group.addChild(filterRow("Map", valuesFor(manager, minigameId, "map"), mapId, value ->
|
||||
open(player, minigameId, value, "", "", sort, store, manager)));
|
||||
group.addChild(filterRow("Variant", valuesFor(manager, minigameId, "variant"), variantId, value ->
|
||||
open(player, minigameId, "", value, "", sort, store, manager)));
|
||||
group.addChild(filterRow("Team", valuesFor(manager, minigameId, "team"), teamId, value ->
|
||||
open(player, minigameId, "", "", value, sort, store, manager)));
|
||||
return group;
|
||||
}
|
||||
|
||||
private GroupBuilder filterRow(String label, List<String> values, String selected, java.util.function.Consumer<String> click) {
|
||||
GroupBuilder row = GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(36))
|
||||
.addChild(spacer(SECTION_INSET, 36))
|
||||
.addChild(kicker(label, 62))
|
||||
.addChild(button("All", selected.isBlank(), 76, () -> click.accept("")));
|
||||
|
||||
for (String value : values.stream().limit(4).toList()) {
|
||||
row.addChild(spacer(6, 36));
|
||||
row.addChild(button(prettify(value), value.equals(selected), 96, () -> click.accept(value)));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private GroupBuilder sortButtons(
|
||||
PlayerRef player,
|
||||
String minigameId,
|
||||
String mapId,
|
||||
String variantId,
|
||||
String teamId,
|
||||
String sort,
|
||||
Store<EntityStore> store,
|
||||
@Nullable TriggerVolumeManager manager
|
||||
) {
|
||||
GroupBuilder row = GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(38))
|
||||
.addChild(spacer(SECTION_INSET, 38))
|
||||
.addChild(button("Score", "score_total".equals(sort), 92,
|
||||
() -> open(player, minigameId, mapId, variantId, teamId, "score_total", store, manager)))
|
||||
.addChild(spacer(6, 38))
|
||||
.addChild(button("Wins", "wins".equals(sort), 92,
|
||||
() -> open(player, minigameId, mapId, variantId, teamId, "wins", store, manager)))
|
||||
.addChild(spacer(6, 38))
|
||||
.addChild(button("Kills", "kills".equals(sort), 92,
|
||||
() -> open(player, minigameId, mapId, variantId, teamId, "kills", store, manager)))
|
||||
.addChild(spacer(6, 38))
|
||||
.addChild(button("Games", "games_played".equals(sort), 92,
|
||||
() -> open(player, minigameId, mapId, variantId, teamId, "games_played", store, manager)))
|
||||
.addChild(spacer(6, 38))
|
||||
.addChild(button("Time", "playtime_ms".equals(sort), 92,
|
||||
() -> open(player, minigameId, mapId, variantId, teamId, "playtime_ms", store, manager)));
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<String> valuesFor(@Nullable TriggerVolumeManager manager, String minigameId, String dimension) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
if ("map".equals(dimension)) {
|
||||
values.addAll(activeMapIds(manager, minigameId));
|
||||
} else if ("variant".equals(dimension)) {
|
||||
values.addAll(activeVariantIds(manager, minigameId));
|
||||
}
|
||||
values.addAll(services.stats().distinctDimensionValues(minigameId, dimension));
|
||||
return new ArrayList<>(values);
|
||||
}
|
||||
|
||||
private static List<String> activeMapIds(@Nullable TriggerVolumeManager manager, String minigameId) {
|
||||
if (manager == null) return List.of();
|
||||
return manager.getVolumes().stream()
|
||||
.filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
|
||||
.filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
|
||||
.map(v -> v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, ""))
|
||||
.filter(id -> !id.isBlank())
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static List<String> activeVariantIds(@Nullable TriggerVolumeManager manager, String minigameId) {
|
||||
if (manager == null) return List.of();
|
||||
return manager.getVolumes().stream()
|
||||
.filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
|
||||
.map(v -> v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_VARIANT_ID, ""))
|
||||
.filter(id -> !id.isBlank())
|
||||
.distinct()
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static GroupBuilder tableHeader() {
|
||||
return GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(24))
|
||||
.addChild(spacer(SECTION_INSET, 24))
|
||||
.addChild(kicker("#", 26))
|
||||
.addChild(kicker("PLAYER", 178))
|
||||
.addChild(kicker("SCORE", 74))
|
||||
.addChild(kicker("W", 42))
|
||||
.addChild(kicker("K/D/A", 88))
|
||||
.addChild(kicker("GAMES", 54))
|
||||
.addChild(kicker("TIME", 70));
|
||||
}
|
||||
|
||||
private static GroupBuilder row(int rank, MinigameStatsRepository.BreakdownRow row) {
|
||||
String name = row.playerUsername() == null || row.playerUsername().isBlank() ? row.playerUuid() : row.playerUsername();
|
||||
String kda = row.kills() + "/" + row.deaths() + "/" + row.assists();
|
||||
return GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(30))
|
||||
.addChild(spacer(SECTION_INSET, 30))
|
||||
.addChild(bodyLabel(Integer.toString(rank), 26))
|
||||
.addChild(bodyLabel(name, 178))
|
||||
.addChild(bodyLabel(Long.toString(row.scoreTotal()), 74))
|
||||
.addChild(bodyLabel(Long.toString(row.wins()), 42))
|
||||
.addChild(bodyLabel(kda, 88))
|
||||
.addChild(bodyLabel(Long.toString(row.gamesPlayed()), 54))
|
||||
.addChild(bodyLabel(formatTime(row.playtimeMs()), 70));
|
||||
}
|
||||
|
||||
private static ButtonBuilder button(String text, boolean selected, int width, Runnable action) {
|
||||
return ButtonBuilder.secondaryTextButton()
|
||||
.withText(selected ? "> " + text : text)
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(32))
|
||||
.onClick(action);
|
||||
}
|
||||
|
||||
private static String subtitle(String mapId, String variantId, String teamId, String sort) {
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (!mapId.isBlank()) parts.add("map " + prettify(mapId));
|
||||
if (!variantId.isBlank()) parts.add("variant " + prettify(variantId));
|
||||
if (!teamId.isBlank()) parts.add("team " + prettify(teamId));
|
||||
parts.add("sorted by " + prettify(sort));
|
||||
return String.join(" | ", parts);
|
||||
}
|
||||
|
||||
private static String formatTime(long millis) {
|
||||
long minutes = Math.max(0, millis / 60_000L);
|
||||
long hours = minutes / 60L;
|
||||
long mins = minutes % 60L;
|
||||
return hours > 0 ? hours + "h " + mins + "m" : mins + "m";
|
||||
}
|
||||
|
||||
private static String prettify(String id) {
|
||||
if (id == null || id.isBlank()) return "All";
|
||||
return Arrays.stream(id.replace('_', ' ').split("\\s+"))
|
||||
.map(w -> w.isEmpty() ? w : Character.toUpperCase(w.charAt(0)) + w.substring(1).toLowerCase())
|
||||
.collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) {
|
||||
return ContainerBuilder.decoratedContainer()
|
||||
.withTitleText(title)
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||
.editElementAfter((commands, selector) -> commands.setObject(
|
||||
selector + " #Content.Anchor",
|
||||
new HyUIAnchor().setTop(0).toHytaleAnchor()
|
||||
));
|
||||
}
|
||||
|
||||
private static GroupBuilder surface(String title, String subtitle, int width, int height) {
|
||||
return GroupBuilder.group()
|
||||
.withLayoutMode("Top")
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||
.withBackground(patch("#172131"))
|
||||
.withOutlineColor("#2b405d")
|
||||
.withOutlineSize(1.0f)
|
||||
.addChild(spacer(width, 8))
|
||||
.addChild(inset(kicker(title, Math.max(80, width - 44)), width, Math.max(80, width - 44), 18))
|
||||
.addChild(inset(caption(subtitle, Math.max(80, width - 44)), width, Math.max(80, width - 44), 24))
|
||||
.addChild(spacer(width, 8));
|
||||
}
|
||||
|
||||
private static GroupBuilder inset(UIElementBuilder<?> child, int outerWidth, int childWidth, int height) {
|
||||
return GroupBuilder.group()
|
||||
.withLayoutMode("Left")
|
||||
.withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height))
|
||||
.addChild(spacer(SECTION_INSET, height))
|
||||
.addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height)));
|
||||
}
|
||||
|
||||
private static LabelBuilder kicker(String text, int width) {
|
||||
return LabelBuilder.label()
|
||||
.withText(text)
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(18))
|
||||
.withStyle(textStyle(11, "#7cc7ff", true)
|
||||
.setRenderUppercase(true).setWrap(false)
|
||||
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||
}
|
||||
|
||||
private static LabelBuilder bodyLabel(String text, int width) {
|
||||
return LabelBuilder.label()
|
||||
.withText(text)
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(20))
|
||||
.withStyle(textStyle(12, "#dbe7f6", false)
|
||||
.setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||
}
|
||||
|
||||
private static LabelBuilder caption(String text, int width) {
|
||||
return LabelBuilder.label()
|
||||
.withText(text)
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(24))
|
||||
.withStyle(textStyle(11, "#9eb4cf", false)
|
||||
.setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||
}
|
||||
|
||||
private static LabelBuilder spacer(int width, int height) {
|
||||
return LabelBuilder.label()
|
||||
.withText(" ")
|
||||
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||
.withStyle(textStyle(1, "#172131", false));
|
||||
}
|
||||
|
||||
private static HyUIStyle textStyle(float size, String color, boolean bold) {
|
||||
return new HyUIStyle()
|
||||
.setFontSize(size)
|
||||
.setTextColor(color)
|
||||
.setRenderBold(bold)
|
||||
.setLetterSpacing(0);
|
||||
}
|
||||
|
||||
private static HyUIPatchStyle patch(String color) {
|
||||
return new HyUIPatchStyle().setColor(color);
|
||||
}
|
||||
|
||||
private record PendingOpen(UUID uuid, String minigameId, long createdAtMillis) {}
|
||||
}
|
||||
@@ -5,17 +5,13 @@ import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
||||
import com.hypixel.hytale.codec.Codec;
|
||||
import com.hypixel.hytale.codec.KeyedCodec;
|
||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||
import com.hypixel.hytale.server.core.Message;
|
||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||
import net.kewwbec.minigames.action.MinigameActionContext;
|
||||
import net.kewwbec.minigames.action.MinigameActionSupport;
|
||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||
import net.kewwbec.minigames.service.MinigameServices;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.awt.Color;
|
||||
import java.util.Optional;
|
||||
|
||||
public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
||||
@@ -38,103 +34,55 @@ public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
||||
|
||||
@Nonnull
|
||||
protected Optional<MinigameRuntime> runtime(@Nonnull TriggerContext context) {
|
||||
MinigameServices services = MinigameCorePlugin.getServices();
|
||||
if (services == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
String declaredId = !candidate.minigameId().isBlank()
|
||||
? candidate.minigameId()
|
||||
: (minigameId != null ? minigameId : "");
|
||||
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||
// Player-first resolution must not hijack a volume that declares a different
|
||||
// minigame: a player from game B walking through game A's volume targets game A.
|
||||
if (playerRuntime.isPresent()
|
||||
&& (declaredId.isBlank() || playerRuntime.get().minigameId().equals(declaredId))) {
|
||||
return playerRuntime;
|
||||
}
|
||||
return runtime(actionContext(context));
|
||||
}
|
||||
|
||||
if (declaredId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||
@Nonnull
|
||||
protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
|
||||
return MinigameActionSupport.runtime(context, minigameId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static MinigameServices services() {
|
||||
return MinigameCorePlugin.getServices();
|
||||
return MinigameActionSupport.services();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
protected String resolvedMinigameId(@Nonnull TriggerContext context) {
|
||||
var services = services();
|
||||
if (services != null) {
|
||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
||||
if (!candidate.minigameId().isBlank()) {
|
||||
return candidate.minigameId();
|
||||
return resolvedMinigameId(actionContext(context));
|
||||
}
|
||||
if (minigameId == null || minigameId.isBlank()) {
|
||||
String playerId = resolvePlayerId(context, null);
|
||||
if (playerId != null) {
|
||||
var playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||
if (playerRuntime.isPresent()) {
|
||||
return playerRuntime.get().minigameId();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return minigameId != null ? minigameId : "";
|
||||
|
||||
@Nonnull
|
||||
protected String resolvedMinigameId(@Nonnull MinigameActionContext context) {
|
||||
return MinigameActionSupport.resolvedMinigameId(context, minigameId);
|
||||
}
|
||||
|
||||
protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) {
|
||||
String id = resolvedMinigameId(context);
|
||||
if (id.isBlank()) return;
|
||||
var def = MinigameDefinition.getAssetMap().getAsset(id);
|
||||
if (def == null || !def.isDebug()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastDebugMs < 5000L) return;
|
||||
lastDebugMs = now;
|
||||
PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
playerRef.sendMessage(
|
||||
Message.translation("minigames.debug.effect")
|
||||
.param("type", effectType)
|
||||
.param("details", details)
|
||||
.color(Color.CYAN)
|
||||
);
|
||||
debugMessage(actionContext(context), effectType, details);
|
||||
}
|
||||
|
||||
protected void debugMessage(@Nonnull MinigameActionContext context, @Nonnull String effectType, @Nonnull String details) {
|
||||
lastDebugMs = MinigameActionSupport.debugMessage(context, minigameId, effectType, details, lastDebugMs);
|
||||
}
|
||||
|
||||
/** Player username for debug output, falling back to the raw id when offline/unresolved. */
|
||||
@Nonnull
|
||||
protected static String displayName(@Nonnull String playerId) {
|
||||
MinigameServices services = services();
|
||||
if (services != null) {
|
||||
String name = services.adapters().getDisplayName(playerId);
|
||||
if (name != null && !name.isBlank()) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return playerId;
|
||||
return MinigameActionSupport.displayName(playerId);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) {
|
||||
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) {
|
||||
return configuredPlayerId;
|
||||
return resolvePlayerId(MinigameActionContext.fromTrigger(context, null), configuredPlayerId);
|
||||
}
|
||||
|
||||
PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
||||
if (playerRef != null) {
|
||||
return playerRef.getUuid().toString();
|
||||
@Nullable
|
||||
protected static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||
return MinigameActionSupport.resolvePlayerId(context, configuredPlayerId);
|
||||
}
|
||||
|
||||
UUIDComponent uuidComponent = context.getStore().getComponent(context.getEntityRef(), UUIDComponent.getComponentType());
|
||||
return uuidComponent != null ? uuidComponent.getUuid().toString() : null;
|
||||
@Nonnull
|
||||
protected MinigameActionContext actionContext(@Nonnull TriggerContext context) {
|
||||
return MinigameActionContext.fromTrigger(context, minigameId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ public final class ModifyScoreEffect extends MinigameRuntimeEffect {
|
||||
return player != null ? clean(player.teamId()) : "";
|
||||
}
|
||||
|
||||
static int apply(int current, Operation operation, int amount) {
|
||||
public static int apply(int current, Operation operation, int amount) {
|
||||
return switch (operation != null ? operation : Operation.ADD) {
|
||||
case SET -> amount;
|
||||
case ADD -> current + amount;
|
||||
|
||||
@@ -38,6 +38,8 @@ command.spec.leave.usage = /minigame leave
|
||||
command.spec.leave.description = Leave the current minigame.
|
||||
command.spec.spectate.usage = /minigame spectate <id>
|
||||
command.spec.spectate.description = Join as a spectator.
|
||||
command.spec.stats.usage = /minigame stats [id]
|
||||
command.spec.stats.description = Open the minigame leaderboard and stats UI.
|
||||
command.spec.volume.create.usage = /minigame volume create <type>
|
||||
command.spec.volume.create.description = Create a minigame volume.
|
||||
command.spec.volume.link.usage = /minigame volume link <volume_id> <minigame_id>
|
||||
@@ -59,6 +61,7 @@ command.spec.enable.arg.id = Minigame ID
|
||||
command.spec.disable.arg.id = Minigame ID
|
||||
command.spec.reset.arg.id = Minigame ID
|
||||
command.spec.validate.arg.id = Minigame ID
|
||||
command.spec.stats.arg.id = Optional minigame ID to open first
|
||||
|
||||
command.error.not_found = Minigame '{id}' was not found.
|
||||
command.error.already_exists = A minigame with id '{id}' already exists.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package net.kewwbec.minigames.interaction;
|
||||
|
||||
import net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||
import net.kewwbec.minigames.volume.effect.*;
|
||||
import org.bson.BsonBoolean;
|
||||
import org.bson.BsonDocument;
|
||||
import org.bson.BsonInt32;
|
||||
import org.bson.BsonString;
|
||||
import org.bson.BsonValue;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class MinigameInteractionsTest {
|
||||
@Test
|
||||
void includedTypeIdsMatchSafeRuntimeEffectScope() {
|
||||
Set<String> expected = Stream.of(
|
||||
StartMinigameEffect.TYPE_ID,
|
||||
EndMinigameEffect.TYPE_ID,
|
||||
ResetMinigameEffect.TYPE_ID,
|
||||
SetMinigamePhaseEffect.TYPE_ID,
|
||||
ResetScoresEffect.TYPE_ID,
|
||||
ModifyScoreEffect.TYPE_ID,
|
||||
ModifyCounterEffect.TYPE_ID,
|
||||
ModifyLivesEffect.TYPE_ID,
|
||||
SetPlayerStatusEffect.TYPE_ID,
|
||||
SetPlayerLoadoutEffect.TYPE_ID,
|
||||
JoinMinigameQueueEffect.TYPE_ID,
|
||||
LeaveMinigameQueueEffect.TYPE_ID,
|
||||
VoteForMapEffect.TYPE_ID,
|
||||
StartQueuedMinigameEffect.TYPE_ID,
|
||||
SetRoundEffect.TYPE_ID,
|
||||
StartTimerEffect.TYPE_ID,
|
||||
StopTimerEffect.TYPE_ID,
|
||||
SetSpawnPointEffect.TYPE_ID,
|
||||
RespawnPlayerEffect.TYPE_ID,
|
||||
RespawnAllPlayersEffect.TYPE_ID,
|
||||
SwapTeamsEffect.TYPE_ID,
|
||||
SendMinigameMessageEffect.TYPE_ID,
|
||||
AssignTeamEffect.TYPE_ID,
|
||||
SaveInventoryEffect.TYPE_ID,
|
||||
RestoreInventoryEffect.TYPE_ID,
|
||||
ClearInventoryEffect.TYPE_ID,
|
||||
SetObjectiveStatusEffect.TYPE_ID,
|
||||
ModifyObjectiveProgressEffect.TYPE_ID,
|
||||
OpenMinigameQueueUIEffect.TYPE_ID,
|
||||
StartPlayerTimerEffect.TYPE_ID,
|
||||
StopPlayerTimerEffect.TYPE_ID
|
||||
)
|
||||
.map(MinigameInteractions::interactionId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
assertEquals(expected, MinigameInteractions.includedTypeIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludedVolumeBoundEffectsDoNotHaveInteractionTypeIds() {
|
||||
Set<String> ids = MinigameInteractions.includedTypeIds();
|
||||
Stream.of(
|
||||
AwardKillScoreEffect.TYPE_ID,
|
||||
SpawnItemEffect.TYPE_ID,
|
||||
SpawnNpcEffect.TYPE_ID,
|
||||
MountPlayerEffect.TYPE_ID,
|
||||
DismountPlayerEffect.TYPE_ID,
|
||||
PlaceBlocksEffect.TYPE_ID
|
||||
)
|
||||
.map(MinigameInteractions::interactionId)
|
||||
.forEach(id -> assertFalse(ids.contains(id), id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesQueueJoinInteractionBaseFields() throws Exception {
|
||||
var interaction = MinigameInteractions.JoinMinigameQueue.CODEC.decode(doc(
|
||||
"MinigameId", "skywars",
|
||||
"MapId", "Mesa",
|
||||
"VariantId", "Doubles"
|
||||
));
|
||||
|
||||
assertEquals("skywars", field(interaction, "minigameId"));
|
||||
assertEquals("Mesa", field(interaction, "mapId"));
|
||||
assertEquals("Doubles", field(interaction, "variantId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesModifyScoreInteractionFields() throws Exception {
|
||||
var interaction = MinigameInteractions.ModifyScore.CODEC.decode(doc(
|
||||
"MinigameId", "arena",
|
||||
"TargetType", "Team",
|
||||
"TargetSource", "TagTeamId",
|
||||
"Operation", "Set",
|
||||
"Amount", 42
|
||||
));
|
||||
|
||||
assertEquals("arena", field(interaction, "minigameId"));
|
||||
assertEquals(ModifyScoreEffect.TargetType.TEAM, field(interaction, "targetType"));
|
||||
assertEquals(ModifyScoreEffect.TargetSource.TAG_TEAM_ID, field(interaction, "targetSource"));
|
||||
assertEquals(ModifyScoreEffect.Operation.SET, field(interaction, "operation"));
|
||||
assertEquals(42, field(interaction, "amount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesPlayerStatusInteractionFields() throws Exception {
|
||||
var interaction = MinigameInteractions.SetPlayerStatus.CODEC.decode(doc(
|
||||
"MinigameId", "ctf",
|
||||
"PlayerId", "player-1",
|
||||
"Status", "Spectator"
|
||||
));
|
||||
|
||||
assertEquals("ctf", field(interaction, "minigameId"));
|
||||
assertEquals("player-1", field(interaction, "playerId"));
|
||||
assertEquals(PlayerStatus.SPECTATOR, field(interaction, "status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesQueueUiInteractionBaseFields() throws Exception {
|
||||
var interaction = MinigameInteractions.OpenMinigameQueueUI.CODEC.decode(doc(
|
||||
"MinigameId", "parkour",
|
||||
"MapId", "forest"
|
||||
));
|
||||
|
||||
assertEquals("parkour", field(interaction, "minigameId"));
|
||||
assertEquals("forest", field(interaction, "mapId"));
|
||||
}
|
||||
|
||||
private static BsonDocument doc(Object... values) {
|
||||
BsonDocument document = new BsonDocument();
|
||||
for (int i = 0; i < values.length; i += 2) {
|
||||
document.put((String) values[i], bson(values[i + 1]));
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private static BsonValue bson(Object value) {
|
||||
if (value instanceof String string) {
|
||||
return new BsonString(string);
|
||||
}
|
||||
if (value instanceof Integer integer) {
|
||||
return new BsonInt32(integer);
|
||||
}
|
||||
if (value instanceof Boolean bool) {
|
||||
return new BsonBoolean(bool);
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported BSON test value: " + value);
|
||||
}
|
||||
|
||||
private static Object field(Object target, String name) throws Exception {
|
||||
Class<?> current = target.getClass();
|
||||
while (current != null) {
|
||||
try {
|
||||
Field field = current.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field.get(target);
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new NoSuchFieldException(name);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user