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:
2026-06-25 09:06:41 -07:00
parent e992f18b8e
commit 7394e8f874
16 changed files with 3089 additions and 96 deletions
+133 -11
View File
@@ -2,14 +2,16 @@
A Hytale-native minigame extension for server-side minigame definitions, runtime state, and trigger-volume logic. 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 ## Features
- Asset-driven minigame definitions in `Server/Minigames/` - Asset-driven minigame definitions in `Server/Minigames/`
- Hytale Asset Editor support through `MinigameDefinition.CODEC` - Hytale Asset Editor support through `MinigameDefinition.CODEC`
- Hytale trigger-volume integration through native `TriggerEffect` and `TriggerCondition` types - 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 `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 - Runtime lifecycle commands for creating, enabling, starting, stopping, and resetting minigames
- Multi-map runtime support using Hytale trigger-volume tags - Multi-map runtime support using Hytale trigger-volume tags
- Player, team, score, lives, counter, status, and phase runtime state - 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. 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: Available condition types:
- `MinigamePhase` - `MinigamePhase`
@@ -85,26 +98,30 @@ Available condition types:
Available effect types: Available effect types:
- `StartMinigame` - `StartMinigame`
- `EndMinigame`
- `ResetMinigame`
- `SetMinigamePhase`
- `ResetScores`
- `ModifyScore`
- `ModifyCounter`
- `ModifyLives`
- `SetPlayerStatus`
- `SetPlayerLoadout`
- `JoinMinigameQueue` - `JoinMinigameQueue`
- `LeaveMinigameQueue` - `LeaveMinigameQueue`
- `VoteForMap` - `VoteForMap`
- `StartQueuedMinigame` - `StartQueuedMinigame`
- `SpawnItem` - `SpawnItem`
- `AwardKillScore` - `SpawnNpc`
- `EndMinigame` - `MountPlayer`
- `ResetMinigame` - `DismountPlayer`
- `SetMinigamePhase`
- `ResetScores`
- `ModifyPlayerScore`
- `ModifyTeamScore`
- `ModifyCounter`
- `ModifyPlayerLives`
- `SetPlayerStatus`
- `SetRound` - `SetRound`
- `StartTimer` - `StartTimer`
- `StopTimer` - `StopTimer`
- `SetSpawnPoint` - `SetSpawnPoint`
- `RespawnPlayer` - `RespawnPlayer`
- `RespawnAllPlayers`
- `SwapTeams`
- `SendMinigameMessage` - `SendMinigameMessage`
- `AssignTeam` - `AssignTeam`
- `SaveInventory` - `SaveInventory`
@@ -112,17 +129,122 @@ Available effect types:
- `ClearInventory` - `ClearInventory`
- `SetObjectiveStatus` - `SetObjectiveStatus`
- `ModifyObjectiveProgress` - `ModifyObjectiveProgress`
- `AwardKillScore`
- `PlaceBlocks`
- `OpenMinigameQueueUI`
- `StartPlayerTimer`
- `StopPlayerTimer`
Fields named `MinigameId` use an editor dropdown backed by registered `MinigameDefinition` assets. 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 ## Project Structure
```text ```text
core-minigames/ core-minigames/
|-- src/main/java/net/kewwbec/minigames/ |-- src/main/java/net/kewwbec/minigames/
| |-- MinigameCorePlugin.java | |-- MinigameCorePlugin.java
| |-- action/
| |-- command/ | |-- command/
| |-- event/ | |-- event/
| |-- interaction/
| |-- model/ | |-- model/
| |-- runtime/ | |-- runtime/
| |-- service/ | |-- service/
@@ -9,6 +9,7 @@ import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
import com.hypixel.hytale.component.ComponentType; import com.hypixel.hytale.component.ComponentType;
import com.hypixel.hytale.codec.builder.BuilderCodec; import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.server.core.io.ServerManager; 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.server.core.universe.world.storage.EntityStore;
import com.hypixel.hytale.logger.HytaleLogger; import com.hypixel.hytale.logger.HytaleLogger;
import com.hypixel.hytale.server.core.asset.HytaleAssetStore; 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 com.hypixel.hytale.server.npc.NPCPlugin;
import net.kewwbec.minigames.adapter.HytaleServerAdapters; import net.kewwbec.minigames.adapter.HytaleServerAdapters;
import net.kewwbec.minigames.command.MinigameCommand; import net.kewwbec.minigames.command.MinigameCommand;
import net.kewwbec.minigames.interaction.MinigameInteractions;
import net.kewwbec.minigames.model.MinigameDefinition; import net.kewwbec.minigames.model.MinigameDefinition;
import net.kewwbec.minigames.model.MinigameVolumeTemplate; import net.kewwbec.minigames.model.MinigameVolumeTemplate;
import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService; 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.MinigameConnectionListener;
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem; import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
import net.kewwbec.minigames.system.MinigameKillScoreSystem; import net.kewwbec.minigames.system.MinigameKillScoreSystem;
import net.kewwbec.minigames.system.MinigameLeaderboardUISystem;
import net.kewwbec.minigames.system.MinigamePregameSystem; import net.kewwbec.minigames.system.MinigamePregameSystem;
import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems; import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems;
import net.kewwbec.minigames.ui.MinigameHudService; import net.kewwbec.minigames.ui.MinigameHudService;
import net.kewwbec.minigames.ui.MinigameHudSystem; import net.kewwbec.minigames.ui.MinigameHudSystem;
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem; import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
import net.kewwbec.minigames.ui.MinigameMenuService; import net.kewwbec.minigames.ui.MinigameMenuService;
import net.kewwbec.minigames.ui.MinigameQueueUIService; import net.kewwbec.minigames.ui.MinigameQueueUIService;
@@ -122,12 +126,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
private static MinigameCorePlugin instance; private static MinigameCorePlugin instance;
private MinigameServices services; private MinigameServices services;
private MinigameQueueUIService queueUIService; private MinigameQueueUIService queueUIService;
private MinigameLeaderboardUIService leaderboardUIService;
private MinigameTemplateBuilderUIService templateBuilderUIService; private MinigameTemplateBuilderUIService templateBuilderUIService;
private StatsDatabase statsDatabase; private StatsDatabase statsDatabase;
private PacketFilter duplicatePacketFilter; private PacketFilter duplicatePacketFilter;
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType; private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
private final java.util.List<String> ownEffectTypeIds = new java.util.ArrayList<>(); 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> ownConditionTypeIds = new java.util.ArrayList<>();
private final java.util.List<String> ownInteractionTypeIds = new java.util.ArrayList<>();
public MinigameCorePlugin(@Nonnull JavaPluginInit init) { public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
super(init); super(init);
@@ -144,6 +150,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
this.duplicatePacketFilter = duplicateSystem.register(); this.duplicatePacketFilter = duplicateSystem.register();
registerTriggerVolumeEffectTypes(); registerTriggerVolumeEffectTypes();
registerTriggerVolumeConditionTypes(); registerTriggerVolumeConditionTypes();
registerMinigameInteractionTypes();
AssetRegistry.register( AssetRegistry.register(
HytaleAssetStore.builder(MinigameDefinition.class, new DefaultAssetMap<String, MinigameDefinition>()) 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 = new MinigameQueueUIService(services);
this.queueUIService.setPlayerAdapter(adapters); this.queueUIService.setPlayerAdapter(adapters);
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService)); getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
this.leaderboardUIService = new MinigameLeaderboardUIService(services);
getEntityStoreRegistry().registerSystem(new MinigameLeaderboardUISystem(leaderboardUIService));
this.templateBuilderUIService = this.templateBuilderUIService =
new MinigameTemplateBuilderUIService(new MinigameTemplateBuilderDataStore(getDataDirectory())); new MinigameTemplateBuilderUIService(new MinigameTemplateBuilderDataStore(getDataDirectory()));
this.templateBuilderUIService.setPlayerAdapter(adapters); this.templateBuilderUIService.setPlayerAdapter(adapters);
@@ -206,10 +215,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
registerTriggerVolumeAssetSources(); registerTriggerVolumeAssetSources();
this.getCommandRegistry().registerCommand(new MinigameCommand(services)); this.getCommandRegistry().registerCommand(new MinigameCommand(services, leaderboardUIService));
injectTriggerVolumeCloneCommand(); injectTriggerVolumeCloneCommand();
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount() 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; duplicatePacketFilter = null;
} }
services = null; services = null;
queueUIService = null;
leaderboardUIService = null;
templateBuilderUIService = null;
instance = null; instance = null;
} }
@@ -262,6 +275,11 @@ public final class MinigameCorePlugin extends JavaPlugin {
return instance != null ? instance.queueUIService : null; return instance != null ? instance.queueUIService : null;
} }
@Nullable
public static MinigameLeaderboardUIService getLeaderboardUIService() {
return instance != null ? instance.leaderboardUIService : null;
}
@Nullable @Nullable
public static MinigameTemplateBuilderUIService getTemplateBuilderUIService() { public static MinigameTemplateBuilderUIService getTemplateBuilderUIService() {
return instance != null ? instance.templateBuilderUIService : null; 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() { private void registerTriggerVolumeConditionTypes() {
registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC); registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC);
registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC); registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC);
@@ -486,4 +549,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
var registered = TriggerCondition.CODEC.getRegisteredIds(); var registered = TriggerCondition.CODEC.getRegisteredIds();
return (int) ownConditionTypeIds.stream().filter(registered::contains).count(); 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 com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
import net.kewwbec.minigames.command.sub.*; import net.kewwbec.minigames.command.sub.*;
import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameServices;
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
public final class MinigameCommand extends AbstractCommandCollection { public final class MinigameCommand extends AbstractCommandCollection {
public MinigameCommand(MinigameServices services) { public MinigameCommand(MinigameServices services, MinigameLeaderboardUIService leaderboardUIService) {
super("minigame", "minigames.command.root.description"); super("minigame", "minigames.command.root.description");
this.setPermissionGroups("hytale:WorldEditor"); this.setPermissionGroups("hytale:WorldEditor");
@@ -25,5 +26,6 @@ public final class MinigameCommand extends AbstractCommandCollection {
addSubCommand(new MinigameJoinCommand(services)); addSubCommand(new MinigameJoinCommand(services));
addSubCommand(new MinigameLeaveCommand(services)); addSubCommand(new MinigameLeaveCommand(services));
addSubCommand(new MinigameSpectateCommand(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); 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) ----------------------------- // --- recording (open-ended; add new stats with no schema change) -----------------------------
public void incrementStat(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, long amount) { 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 { private static AggregateRow readAggregate(ResultSet rs) throws SQLException {
int bestPlacement = rs.getInt("best_placement"); int bestPlacement = rs.getInt("best_placement");
return new AggregateRow( 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). */ /** Whitelist guard: only the fixed aggregate columns may be sorted on (avoids SQL injection). */
private static String sanitizeOrderColumn(String requested) { private static String sanitizeOrderColumn(String requested) {
return switch (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. */ /** Read view of a lifetime aggregate row. */
public record AggregateRow( public record AggregateRow(
String playerUuid, String playerUuid,
@@ -226,4 +348,21 @@ public final class MinigameStatsRepository {
@Nullable String lastPlayedAt @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.Codec;
import com.hypixel.hytale.codec.KeyedCodec; import com.hypixel.hytale.codec.KeyedCodec;
import com.hypixel.hytale.codec.builder.BuilderCodec; import com.hypixel.hytale.codec.builder.BuilderCodec;
import com.hypixel.hytale.server.core.Message; import net.kewwbec.minigames.action.MinigameActionContext;
import com.hypixel.hytale.server.core.entity.UUIDComponent; import net.kewwbec.minigames.action.MinigameActionSupport;
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.runtime.MinigameRuntime;
import net.kewwbec.minigames.service.MinigameServices; import net.kewwbec.minigames.service.MinigameServices;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.awt.Color;
import java.util.Optional; import java.util.Optional;
public abstract class MinigameRuntimeEffect extends TriggerEffect { public abstract class MinigameRuntimeEffect extends TriggerEffect {
@@ -38,103 +34,55 @@ public abstract class MinigameRuntimeEffect extends TriggerEffect {
@Nonnull @Nonnull
protected Optional<MinigameRuntime> runtime(@Nonnull TriggerContext context) { protected Optional<MinigameRuntime> runtime(@Nonnull TriggerContext context) {
MinigameServices services = MinigameCorePlugin.getServices(); return runtime(actionContext(context));
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;
}
} }
if (declaredId.isBlank()) { @Nonnull
return Optional.empty(); protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
} return MinigameActionSupport.runtime(context, minigameId);
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
} }
@Nullable @Nullable
protected static MinigameServices services() { protected static MinigameServices services() {
return MinigameCorePlugin.getServices(); return MinigameActionSupport.services();
} }
@Nonnull @Nonnull
protected String resolvedMinigameId(@Nonnull TriggerContext context) { protected String resolvedMinigameId(@Nonnull TriggerContext context) {
var services = services(); return resolvedMinigameId(actionContext(context));
if (services != null) {
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
if (!candidate.minigameId().isBlank()) {
return candidate.minigameId();
} }
if (minigameId == null || minigameId.isBlank()) {
String playerId = resolvePlayerId(context, null); @Nonnull
if (playerId != null) { protected String resolvedMinigameId(@Nonnull MinigameActionContext context) {
var playerRuntime = services.runtime().runtimeForPlayer(playerId); return MinigameActionSupport.resolvedMinigameId(context, minigameId);
if (playerRuntime.isPresent()) {
return playerRuntime.get().minigameId();
}
}
}
}
return minigameId != null ? minigameId : "";
} }
protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) { protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) {
String id = resolvedMinigameId(context); debugMessage(actionContext(context), effectType, details);
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)
);
} }
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. */ /** Player username for debug output, falling back to the raw id when offline/unresolved. */
@Nonnull @Nonnull
protected static String displayName(@Nonnull String playerId) { protected static String displayName(@Nonnull String playerId) {
MinigameServices services = services(); return MinigameActionSupport.displayName(playerId);
if (services != null) {
String name = services.adapters().getDisplayName(playerId);
if (name != null && !name.isBlank()) {
return name;
}
}
return playerId;
} }
@Nullable @Nullable
protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) { protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) {
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) { return resolvePlayerId(MinigameActionContext.fromTrigger(context, null), configuredPlayerId);
return configuredPlayerId;
} }
PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType()); @Nullable
if (playerRef != null) { protected static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
return playerRef.getUuid().toString(); return MinigameActionSupport.resolvePlayerId(context, configuredPlayerId);
} }
UUIDComponent uuidComponent = context.getStore().getComponent(context.getEntityRef(), UUIDComponent.getComponentType()); @Nonnull
return uuidComponent != null ? uuidComponent.getUuid().toString() : null; 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()) : ""; 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) { return switch (operation != null ? operation : Operation.ADD) {
case SET -> amount; case SET -> amount;
case ADD -> current + 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.leave.description = Leave the current minigame.
command.spec.spectate.usage = /minigame spectate <id> command.spec.spectate.usage = /minigame spectate <id>
command.spec.spectate.description = Join as a spectator. 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.usage = /minigame volume create <type>
command.spec.volume.create.description = Create a minigame volume. command.spec.volume.create.description = Create a minigame volume.
command.spec.volume.link.usage = /minigame volume link <volume_id> <minigame_id> 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.disable.arg.id = Minigame ID
command.spec.reset.arg.id = Minigame ID command.spec.reset.arg.id = Minigame ID
command.spec.validate.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.not_found = Minigame '{id}' was not found.
command.error.already_exists = A minigame with id '{id}' already exists. 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);
}
}