diff --git a/README.md b/README.md index a446708..8d63753 100644 --- a/README.md +++ b/README.md @@ -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=`, `MapId=`, optional `VariantId=`, and `MapRole=MainArena` on the primary arena volume. Team spawn volumes use `SpawnRole=TeamSpawn` and `TeamId=`. `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/ diff --git a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java index 3d29712..0b4636e 100644 --- a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java +++ b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java @@ -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 lockMountComponentType; private final java.util.List ownEffectTypeIds = new java.util.ArrayList<>(); private final java.util.List ownConditionTypeIds = new java.util.ArrayList<>(); + private final java.util.List 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()) @@ -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 void registerMinigameInteractionType( + @Nonnull String typeId, + @Nonnull Class clazz, + @Nonnull BuilderCodec 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(); + } } diff --git a/src/main/java/net/kewwbec/minigames/action/MinigameActionContext.java b/src/main/java/net/kewwbec/minigames/action/MinigameActionContext.java new file mode 100644 index 0000000..1d6ac84 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/action/MinigameActionContext.java @@ -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 entityRef(); + + @Nonnull + Store 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 entityRef, + @Nonnull Store 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 entityRef() { + return context.getEntityRef(); + } + + @Nonnull + @Override + public Store 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 tags = context.getVolume() != null ? context.getVolume().getRawTags() : Map.of(); + return tags.get(key); + } + } + + final class InteractionBacked implements MinigameActionContext { + @Nonnull + private final Ref entityRef; + @Nonnull + private final Store 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 entityRef, + @Nonnull Store 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 entityRef() { + return entityRef; + } + + @Nonnull + @Override + public Store 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; + }; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/action/MinigameActionSupport.java b/src/main/java/net/kewwbec/minigames/action/MinigameActionSupport.java new file mode 100644 index 0000000..25090aa --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/action/MinigameActionSupport.java @@ -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 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 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; + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java b/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java index 045322e..29b0b0b 100644 --- a/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java +++ b/src/main/java/net/kewwbec/minigames/command/MinigameCommand.java @@ -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)); } } diff --git a/src/main/java/net/kewwbec/minigames/command/sub/MinigameStatsCommand.java b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStatsCommand.java new file mode 100644 index 0000000..a5bf50d --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/sub/MinigameStatsCommand.java @@ -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 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.")); + } +} diff --git a/src/main/java/net/kewwbec/minigames/interaction/MinigameInteractions.java b/src/main/java/net/kewwbec/minigames/interaction/MinigameInteractions.java new file mode 100644 index 0000000..e927207 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/interaction/MinigameInteractions.java @@ -0,0 +1,1637 @@ +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.codec.codecs.EnumCodec; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import net.kewwbec.minigames.MinigameCorePlugin; +import net.kewwbec.minigames.action.MinigameActionContext; +import net.kewwbec.minigames.model.Enums.MinigamePhase; +import net.kewwbec.minigames.model.Enums.ObjectiveStatus; +import net.kewwbec.minigames.model.Enums.PlayerStatus; +import net.kewwbec.minigames.model.MinigameMapCandidate; +import net.kewwbec.minigames.model.ObjectiveState; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.model.TeamState; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import net.kewwbec.minigames.service.MinigameMapDiscoveryService; +import net.kewwbec.minigames.service.MinigameQueueService; +import net.kewwbec.minigames.service.MinigameServices; +import net.kewwbec.minigames.service.MinigameStarter; +import net.kewwbec.minigames.ui.MinigameQueueUIService; +import net.kewwbec.minigames.volume.effect.AssignTeamEffect; +import net.kewwbec.minigames.volume.effect.ClearInventoryEffect; +import net.kewwbec.minigames.volume.effect.EndMinigameEffect; +import net.kewwbec.minigames.volume.effect.JoinMinigameQueueEffect; +import net.kewwbec.minigames.volume.effect.LeaveMinigameQueueEffect; +import net.kewwbec.minigames.volume.effect.ModifyCounterEffect; +import net.kewwbec.minigames.volume.effect.ModifyLivesEffect; +import net.kewwbec.minigames.volume.effect.ModifyObjectiveProgressEffect; +import net.kewwbec.minigames.volume.effect.ModifyScoreEffect; +import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect; +import net.kewwbec.minigames.volume.effect.ResetMinigameEffect; +import net.kewwbec.minigames.volume.effect.ResetScoresEffect; +import net.kewwbec.minigames.volume.effect.RespawnAllPlayersEffect; +import net.kewwbec.minigames.volume.effect.RespawnPlayerEffect; +import net.kewwbec.minigames.volume.effect.RestoreInventoryEffect; +import net.kewwbec.minigames.volume.effect.SaveInventoryEffect; +import net.kewwbec.minigames.volume.effect.SendMinigameMessageEffect; +import net.kewwbec.minigames.volume.effect.SetMinigamePhaseEffect; +import net.kewwbec.minigames.volume.effect.SetObjectiveStatusEffect; +import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect; +import net.kewwbec.minigames.volume.effect.SetPlayerStatusEffect; +import net.kewwbec.minigames.volume.effect.SetRoundEffect; +import net.kewwbec.minigames.volume.effect.SetSpawnPointEffect; +import net.kewwbec.minigames.volume.effect.StartMinigameEffect; +import net.kewwbec.minigames.volume.effect.StartPlayerTimerEffect; +import net.kewwbec.minigames.volume.effect.StartQueuedMinigameEffect; +import net.kewwbec.minigames.volume.effect.StartTimerEffect; +import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect; +import net.kewwbec.minigames.volume.effect.StopTimerEffect; +import net.kewwbec.minigames.volume.effect.SwapTeamsEffect; +import net.kewwbec.minigames.volume.effect.VoteForMapEffect; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class MinigameInteractions { + private static final String PREFIX = "Minigame"; + + private MinigameInteractions() { + } + + @Nonnull + public static String interactionId(@Nonnull String effectTypeId) { + return PREFIX + effectTypeId; + } + + @Nonnull + public static Set includedTypeIds() { + return Set.of( + StartMinigame.TYPE_ID, + EndMinigame.TYPE_ID, + ResetMinigame.TYPE_ID, + SetMinigamePhase.TYPE_ID, + ResetScores.TYPE_ID, + ModifyScore.TYPE_ID, + ModifyCounter.TYPE_ID, + ModifyLives.TYPE_ID, + SetPlayerStatus.TYPE_ID, + SetPlayerLoadout.TYPE_ID, + JoinMinigameQueue.TYPE_ID, + LeaveMinigameQueue.TYPE_ID, + VoteForMap.TYPE_ID, + StartQueuedMinigame.TYPE_ID, + SetRound.TYPE_ID, + StartTimer.TYPE_ID, + StopTimer.TYPE_ID, + SetSpawnPoint.TYPE_ID, + RespawnPlayer.TYPE_ID, + RespawnAllPlayers.TYPE_ID, + SwapTeams.TYPE_ID, + SendMinigameMessage.TYPE_ID, + AssignTeam.TYPE_ID, + SaveInventory.TYPE_ID, + RestoreInventory.TYPE_ID, + ClearInventory.TYPE_ID, + SetObjectiveStatus.TYPE_ID, + ModifyObjectiveProgress.TYPE_ID, + OpenMinigameQueueUI.TYPE_ID, + StartPlayerTimer.TYPE_ID, + StopPlayerTimer.TYPE_ID + ); + } + + public static final class StartMinigame extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StartMinigameEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartMinigame.class, StartMinigame::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + String id = resolvedMinigameId(context); + if (services == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " id='" + id + "')"); + return; + } + var defOpt = services.minigames().definition(id); + if (defOpt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + return; + } + String worldName = context.store().getExternalData() != null && context.store().getExternalData().getWorld() != null + ? context.store().getExternalData().getWorld().getName() + : null; + services.runtime().start(defOpt.get(), context.candidate(id), worldName, false); + debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "'"); + } + } + + public static final class EndMinigame extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(EndMinigameEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(EndMinigame.class, EndMinigame::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("Reason", Codec.STRING, false), + (interaction, value) -> interaction.reason = value, + interaction -> interaction.reason, + (interaction, parent) -> interaction.reason = parent.reason) + .add() + .build(); + + private String reason = "minigames.runtime.reason.interaction"; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services null)"); + return; + } + var rt = runtime(context); + if (rt.isPresent()) { + String stopReason = reason != null ? reason : "minigames.runtime.reason.interaction"; + services.runtime().endRuntime(rt.get().runtimeId(), stopReason); + debugMessage(context, TYPE_ID, "FIRED reason='" + stopReason + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } + } + + public static final class ResetMinigame extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ResetMinigameEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ResetMinigame.class, ResetMinigame::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services null)"); + return; + } + var rt = runtime(context); + if (rt.isPresent()) { + services.runtime().resetRuntime(rt.get().runtimeId()); + debugMessage(context, TYPE_ID, "FIRED minigame='" + resolvedMinigameId(context) + "'"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } + } + + public static final class SetMinigamePhase extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetMinigamePhaseEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetMinigamePhase.class, SetMinigamePhase::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("Phase", new EnumCodec<>(MinigamePhase.class)), + (interaction, value) -> interaction.phase = value, + interaction -> interaction.phase, + (interaction, parent) -> interaction.phase = parent.phase) + .add() + .build(); + + private MinigamePhase phase = MinigamePhase.ACTIVE; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + MinigamePhase target = phase != null ? phase : MinigamePhase.ACTIVE; + var rt = runtime(context); + var services = services(); + if (rt.isEmpty() || services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + switch (target) { + case ENDING -> services.runtime().endRuntime(rt.get().runtimeId(), "minigames.runtime.reason.interaction"); + case RESETTING -> services.runtime().resetRuntime(rt.get().runtimeId()); + default -> services.runtime().setPhase(rt.get(), target); + } + debugMessage(context, TYPE_ID, "FIRED phase=" + target); + } + } + + public static final class ResetScores extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ResetScoresEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ResetScores.class, ResetScores::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isPresent()) { + rt.get().players().values().forEach(player -> player.score(0)); + rt.get().teams().values().forEach(team -> team.score(0)); + debugMessage(context, TYPE_ID, "FIRED reset all scores"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } + } + + public static final class ModifyScore extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ModifyScoreEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ModifyScore.class, ModifyScore::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("TargetType", new EnumCodec<>(ModifyScoreEffect.TargetType.class), false), + (interaction, value) -> interaction.targetType = value, + interaction -> interaction.targetType, + (interaction, parent) -> interaction.targetType = parent.targetType) + .add() + .appendInherited(new KeyedCodec<>("TargetSource", new EnumCodec<>(ModifyScoreEffect.TargetSource.class), false), + (interaction, value) -> interaction.targetSource = value, + interaction -> interaction.targetSource, + (interaction, parent) -> interaction.targetSource = parent.targetSource) + .add() + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .appendInherited(new KeyedCodec<>("Amount", Codec.INTEGER, false), + (interaction, value) -> interaction.amount = value, + interaction -> interaction.amount, + (interaction, parent) -> interaction.amount = parent.amount) + .add() + .build(); + + private ModifyScoreEffect.TargetType targetType = ModifyScoreEffect.TargetType.PLAYER; + private ModifyScoreEffect.TargetSource targetSource = ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER; + private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD; + private int amount = 1; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + if ((targetType != null ? targetType : ModifyScoreEffect.TargetType.PLAYER) == ModifyScoreEffect.TargetType.TEAM) { + modifyTeamScore(context, rt.get()); + } else { + modifyPlayerScores(context, rt.get()); + } + } + + private void modifyPlayerScores(@Nonnull MinigameActionContext context, @Nonnull MinigameRuntime runtime) { + List playerIds = resolvePlayerTargets(context, runtime, targetSource); + if (playerIds.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no player target resolved)"); + return; + } + int changed = 0; + for (String playerId : playerIds) { + PlayerMinigameState player = runtime.players().get(playerId); + if (player == null) continue; + int prev = player.score(); + int next = ModifyScoreEffect.apply(prev, operation, amount); + player.score(next); + changed++; + debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' score " + prev + "->" + next); + } + if (changed == 0) { + debugMessage(context, TYPE_ID, "SKIPPED (resolved player targets not in runtime)"); + } + } + + private void modifyTeamScore(@Nonnull MinigameActionContext context, @Nonnull MinigameRuntime runtime) { + String teamId = resolveTeamId(context, runtime, targetSource); + if (teamId == null || teamId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no team target resolved)"); + return; + } + var team = runtime.teams().get(teamId); + if (team == null) { + debugMessage(context, TYPE_ID, "SKIPPED (team='" + teamId + "' not in runtime)"); + return; + } + int prev = team.score(); + int next = ModifyScoreEffect.apply(prev, operation, amount); + team.score(next); + debugMessage(context, TYPE_ID, "FIRED team='" + teamId + "' score " + prev + "->" + next); + } + } + + public static final class ModifyCounter extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ModifyCounterEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ModifyCounter.class, ModifyCounter::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("CounterId", Codec.STRING), + (interaction, value) -> interaction.counterId = value, + interaction -> interaction.counterId, + (interaction, parent) -> interaction.counterId = parent.counterId) + .add() + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .appendInherited(new KeyedCodec<>("Amount", Codec.INTEGER, false), + (interaction, value) -> interaction.amount = value, + interaction -> interaction.amount, + (interaction, parent) -> interaction.amount = parent.amount) + .add() + .build(); + + private String counterId; + private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD; + private int amount = 1; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + if (counterId == null || counterId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (counterId blank)"); + return; + } + var rt = runtime(context); + if (rt.isPresent()) { + String key = "counter:" + counterId; + int current = number(rt.get().variables().get(key)); + int next = ModifyScoreEffect.apply(current, operation, amount); + rt.get().variables().put(key, next); + debugMessage(context, TYPE_ID, "FIRED counter='" + counterId + "' " + current + "->" + next); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + } + } + } + + public static final class ModifyLives extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ModifyLivesEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ModifyLives.class, ModifyLives::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("TargetType", new EnumCodec<>(ModifyScoreEffect.TargetType.class), false), + (interaction, value) -> interaction.targetType = value, + interaction -> interaction.targetType, + (interaction, parent) -> interaction.targetType = parent.targetType) + .add() + .appendInherited(new KeyedCodec<>("TargetSource", new EnumCodec<>(ModifyScoreEffect.TargetSource.class), false), + (interaction, value) -> interaction.targetSource = value, + interaction -> interaction.targetSource, + (interaction, parent) -> interaction.targetSource = parent.targetSource) + .add() + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .appendInherited(new KeyedCodec<>("Amount", Codec.INTEGER, false), + (interaction, value) -> interaction.amount = value, + interaction -> interaction.amount, + (interaction, parent) -> interaction.amount = parent.amount) + .add() + .build(); + + private ModifyScoreEffect.TargetType targetType = ModifyScoreEffect.TargetType.PLAYER; + private ModifyScoreEffect.TargetSource targetSource = ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER; + private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD; + private int amount = 1; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + List targets = resolvePlayerTargets(context, rt.get(), targetSource); + if (targets.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no player target resolved)"); + return; + } + int changed = 0; + for (String playerId : targets) { + PlayerMinigameState player = rt.get().players().get(playerId); + if (player == null) continue; + int prev = player.lives(); + int next = Math.max(0, ModifyScoreEffect.apply(prev, operation, amount)); + player.lives(next); + changed++; + debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' lives " + prev + "->" + next); + if (prev > 0 && next <= 0 && player.status() == PlayerStatus.ACTIVE) { + player.status(PlayerStatus.ELIMINATED); + rt.get().eliminatedPlayers().add(playerId); + var svc = services(); + if (svc != null) { + svc.runtime().dispatch(rt.get(), "on_player_eliminated", + Map.of("player_id", playerId, "cause", "lives_depleted")); + } + } + } + if (changed == 0) { + debugMessage(context, TYPE_ID, "SKIPPED (resolved player targets not in runtime)"); + } + } + } + + public static final class SetPlayerStatus extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetPlayerStatusEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetPlayerStatus.class, SetPlayerStatus::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("Status", new EnumCodec<>(PlayerStatus.class)), + (interaction, value) -> interaction.status = value, + interaction -> interaction.status, + (interaction, parent) -> interaction.status = parent.status) + .add() + .build(); + + private String playerId; + private PlayerStatus status = PlayerStatus.ACTIVE; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE; + var svc = services(); + boolean wasSpectator = rt.get().spectators().contains(id); + player.status(resolvedStatus); + if (resolvedStatus == PlayerStatus.SPECTATOR) { + if (svc != null) svc.runtime().onPlayerSpectate(rt.get(), id); + else rt.get().spectators().add(id); + } else { + rt.get().spectators().remove(id); + if (wasSpectator && resolvedStatus != PlayerStatus.ACTIVE && svc != null) { + svc.adapters().clearSpectatorState(id); + } + } + if (resolvedStatus == PlayerStatus.ACTIVE) { + rt.get().eliminatedPlayers().remove(id); + if (svc != null) svc.runtime().onPlayerActivated(rt.get(), id); + } else if (resolvedStatus == PlayerStatus.ELIMINATED) { + rt.get().eliminatedPlayers().add(id); + if (svc != null) { + svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id)); + } + } else { + rt.get().eliminatedPlayers().remove(id); + } + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' status=" + resolvedStatus); + } + } + + public static final class SetPlayerLoadout extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetPlayerLoadoutEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetPlayerLoadout.class, SetPlayerLoadout::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("LoadoutId", Codec.STRING, false), + (interaction, value) -> interaction.loadoutId = value, + interaction -> interaction.loadoutId, + (interaction, parent) -> interaction.loadoutId = parent.loadoutId) + .add() + .build(); + + private String playerId; + private String loadoutId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + if (rt.get().players().get(id) == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' " + SetPlayerLoadoutEffect.grantLoadout(services, rt.get(), id, loadoutId)); + } + } + + public static final class JoinMinigameQueue extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(JoinMinigameQueueEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(JoinMinigameQueue.class, JoinMinigameQueue::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + String playerId = resolvePlayerId(context, null); + String id = resolvedMinigameId(context); + if (services == null || playerId == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')"); + return; + } + var defOpt = services.minigames().definition(id); + if (defOpt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + return; + } + MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), playerId); + switch (result) { + case JOINED -> debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' joined queue for minigame='" + id + "'"); + case ALREADY_QUEUED -> debugMessage(context, TYPE_ID, "SKIPPED (player='" + playerId + "' already queued)"); + case QUEUE_FULL -> { + services.adapters().sendMessage(playerId, Message.translation("minigames.queue.full").param("id", id)); + debugMessage(context, TYPE_ID, "SKIPPED (queue full for minigame='" + id + "')"); + } + case DISABLED -> { + services.adapters().sendMessage(playerId, Message.translation("minigames.queue.disabled").param("id", id)); + debugMessage(context, TYPE_ID, "SKIPPED (minigame='" + id + "' is disabled)"); + } + } + } + } + + public static final class LeaveMinigameQueue extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(LeaveMinigameQueueEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(LeaveMinigameQueue.class, LeaveMinigameQueue::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + String playerId = resolvePlayerId(context, null); + String id = resolvedMinigameId(context); + if (services == null || playerId == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')"); + return; + } + services.queue().leave(id, playerId); + services.runtime().runtimesForMinigame(id).forEach(runtime -> { + var phase = runtime.phase(); + if (phase == MinigamePhase.WAITING_FOR_PLAYERS + || phase == MinigamePhase.COUNTDOWN + || phase == MinigamePhase.STARTING) { + services.runtime().removePlayer(runtime, playerId, "left_queue"); + debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'"); + } + }); + debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'"); + } + } + + public static final class VoteForMap extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(VoteForMapEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(VoteForMap.class, VoteForMap::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + String playerId = resolvePlayerId(context, null); + String id = resolvedMinigameId(context); + String selectedMapId = context.candidate(id).mapId(); + if (services != null && playerId != null && !id.isBlank() && !selectedMapId.isBlank()) { + boolean voted = services.queue().vote(id, playerId, selectedMapId); + debugMessage(context, TYPE_ID, voted + ? "FIRED player='" + playerId + "' voted map='" + selectedMapId + "' minigame='" + id + "'" + : "SKIPPED (player='" + playerId + "' is not queued for minigame='" + id + "')"); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "' map='" + selectedMapId + "')"); + } + } + } + + public static final class StartQueuedMinigame extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StartQueuedMinigameEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartQueuedMinigame.class, StartQueuedMinigame::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var services = services(); + String id = resolvedMinigameId(context); + if (services == null || id.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " id='" + id + "')"); + return; + } + var defOpt = services.minigames().definition(id); + if (defOpt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')"); + return; + } + var def = defOpt.get(); + if (!services.runtime().runtimesForMinigame(id).isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (runtime already active for id='" + id + "')"); + return; + } + int queued = services.queue().players(id).size(); + if (queued < def.getMinPlayers()) { + debugMessage(context, TYPE_ID, "SKIPPED (queue=" + queued + " < minPlayers=" + def.getMinPlayers() + ")"); + return; + } + var runtime = MinigameStarter.startQueued(services, def, context.store(), false); + if (runtime != null) { + debugMessage(context, TYPE_ID, "FIRED minigame='" + id + "' runtime=" + runtime.runtimeId()); + } else { + debugMessage(context, TYPE_ID, "SKIPPED (start refused - check world restriction / activation volume)"); + } + } + } + + public static final class SetRound extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetRoundEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetRound.class, SetRound::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .appendInherited(new KeyedCodec<>("Amount", Codec.INTEGER, false), + (interaction, value) -> interaction.amount = value, + interaction -> interaction.amount, + (interaction, parent) -> interaction.amount = parent.amount) + .add() + .build(); + + private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.SET; + private int amount = 1; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + int current = rt.get().roundNumber(); + int next = Math.max(1, ModifyScoreEffect.apply(current, operation, amount)); + rt.get().roundNumber(next); + var services = services(); + if (services != null) { + services.signals().onRoundStart(rt.get(), next); + int roundLen = rt.get().definition().getRoundLengthSeconds(); + if (roundLen > 0) { + rt.get().variables().put("$roundTimerTarget", next); + services.signals().startTimer(rt.get(), "$round", roundLen * 1000L); + } + services.runtime().dispatch(rt.get(), "on_round_start", Map.of("round", next)); + } + debugMessage(context, TYPE_ID, "FIRED round " + current + "->" + next); + } + } + + public static final class StartTimer extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StartTimerEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartTimer.class, StartTimer::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("TimerName", Codec.STRING), + (interaction, value) -> interaction.timerName = value, + interaction -> interaction.timerName, + (interaction, parent) -> interaction.timerName = parent.timerName) + .add() + .appendInherited(new KeyedCodec<>("DurationSeconds", Codec.INTEGER), + (interaction, value) -> interaction.durationSeconds = value, + interaction -> interaction.durationSeconds, + (interaction, parent) -> interaction.durationSeconds = parent.durationSeconds) + .add() + .build(); + + private String timerName; + private int durationSeconds = 60; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + if (timerName == null || timerName.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no timer name configured)"); + return; + } + long durationMs = Math.max(1, durationSeconds) * 1000L; + var services = services(); + if (services != null) services.signals().startTimer(rt.get(), timerName, durationMs); + else rt.get().timers().put(timerName, System.currentTimeMillis() + durationMs); + debugMessage(context, TYPE_ID, "FIRED timer='" + timerName + "' duration=" + durationSeconds + "s"); + } + } + + public static final class StopTimer extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StopTimerEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StopTimer.class, StopTimer::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("TimerName", Codec.STRING), + (interaction, value) -> interaction.timerName = value, + interaction -> interaction.timerName, + (interaction, parent) -> interaction.timerName = parent.timerName) + .add() + .build(); + + private String timerName; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + if (timerName == null || timerName.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no timer name configured)"); + return; + } + rt.get().timers().remove(timerName); + var services = services(); + if (services != null) services.signals().cancelTimer(rt.get().runtimeId(), timerName); + debugMessage(context, TYPE_ID, "FIRED timer='" + timerName + "' stopped"); + } + } + + public static final class SetSpawnPoint extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetSpawnPointEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetSpawnPoint.class, SetSpawnPoint::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("X", Codec.DOUBLE, false), + (interaction, value) -> interaction.x = value, + interaction -> interaction.x, + (interaction, parent) -> interaction.x = parent.x) + .add() + .appendInherited(new KeyedCodec<>("Y", Codec.DOUBLE, false), + (interaction, value) -> interaction.y = value, + interaction -> interaction.y, + (interaction, parent) -> interaction.y = parent.y) + .add() + .appendInherited(new KeyedCodec<>("Z", Codec.DOUBLE, false), + (interaction, value) -> interaction.z = value, + interaction -> interaction.z, + (interaction, parent) -> interaction.z = parent.z) + .add() + .build(); + + private String playerId; + private double x = 0.0D; + private double y = 0.0D; + private double z = 0.0D; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + String destination = x + "," + y + "," + z; + player.checkpointId(destination); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' spawnPoint='" + destination + "'"); + } + } + + public static final class RespawnPlayer extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(RespawnPlayerEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(RespawnPlayer.class, RespawnPlayer::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("DestinationId", Codec.STRING, false), + (interaction, value) -> interaction.destinationId = value, + interaction -> interaction.destinationId, + (interaction, parent) -> interaction.destinationId = parent.destinationId) + .add() + .build(); + + private String playerId; + private String destinationId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + var services = services(); + String dest = services != null ? services.maps().respawnDestination(context.store(), rt.get(), player, destinationId) : ""; + if (dest.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' has no spawn point or team spawn set)"); + return; + } + services.adapters().teleport(id, dest); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' -> '" + dest + "'"); + } + } + + public static final class RespawnAllPlayers extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(RespawnAllPlayersEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(RespawnAllPlayers.class, RespawnAllPlayers::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("DestinationId", Codec.STRING, false), + (interaction, value) -> interaction.destinationId = value, + interaction -> interaction.destinationId, + (interaction, parent) -> interaction.destinationId = parent.destinationId) + .add() + .appendInherited(new KeyedCodec<>("ClearCheckpoints", Codec.BOOLEAN, false), + (interaction, value) -> interaction.clearCheckpoints = value, + interaction -> interaction.clearCheckpoints, + (interaction, parent) -> interaction.clearCheckpoints = parent.clearCheckpoints) + .add() + .appendInherited(new KeyedCodec<>("ActiveOnly", Codec.BOOLEAN, false), + (interaction, value) -> interaction.activeOnly = value, + interaction -> interaction.activeOnly, + (interaction, parent) -> interaction.activeOnly = parent.activeOnly) + .add() + .appendInherited(new KeyedCodec<>("ReactivateEliminated", Codec.BOOLEAN, false), + (interaction, value) -> interaction.reactivateEliminated = value, + interaction -> interaction.reactivateEliminated, + (interaction, parent) -> interaction.reactivateEliminated = parent.reactivateEliminated) + .add() + .build(); + + private String destinationId; + private boolean clearCheckpoints = false; + private boolean activeOnly = true; + private boolean reactivateEliminated = false; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + MinigameServices services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no services)"); + return; + } + int respawned = 0; + for (Map.Entry entry : rt.get().players().entrySet()) { + String id = entry.getKey(); + PlayerMinigameState player = entry.getValue(); + if (reactivateEliminated && player.status() == PlayerStatus.ELIMINATED) { + player.status(PlayerStatus.ACTIVE); + rt.get().eliminatedPlayers().remove(id); + rt.get().spectators().remove(id); + services.runtime().onPlayerActivated(rt.get(), id); + } + if (activeOnly && player.status() != PlayerStatus.ACTIVE) continue; + if (clearCheckpoints) player.checkpointId(null); + String dest = services.maps().respawnDestination(context.store(), rt.get(), player, destinationId); + if (dest.isBlank()) continue; + services.adapters().teleport(id, dest); + respawned++; + } + debugMessage(context, TYPE_ID, "FIRED respawned=" + respawned); + } + } + + public static final class SwapTeams extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SwapTeamsEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SwapTeams.class, SwapTeams::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("Rotation", Codec.INTEGER, false), + (interaction, value) -> interaction.rotation = value, + interaction -> interaction.rotation, + (interaction, parent) -> interaction.rotation = parent.rotation) + .add() + .appendInherited(new KeyedCodec<>("ClearCheckpoints", Codec.BOOLEAN, false), + (interaction, value) -> interaction.clearCheckpoints = value, + interaction -> interaction.clearCheckpoints, + (interaction, parent) -> interaction.clearCheckpoints = parent.clearCheckpoints) + .add() + .appendInherited(new KeyedCodec<>("Respawn", Codec.BOOLEAN, false), + (interaction, value) -> interaction.respawn = value, + interaction -> interaction.respawn, + (interaction, parent) -> interaction.respawn = parent.respawn) + .add() + .build(); + + private int rotation = 1; + private boolean clearCheckpoints = true; + private boolean respawn = true; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + List teams = new ArrayList<>(rt.get().teams().values()); + teams.sort(Comparator.comparing(TeamState::teamId)); + int n = teams.size(); + if (n < 2) { + debugMessage(context, TYPE_ID, "SKIPPED (needs at least 2 teams, found " + n + ")"); + return; + } + int shift = ((rotation % n) + n) % n; + if (shift == 0) { + debugMessage(context, TYPE_ID, "SKIPPED (rotation " + rotation + " is a no-op for " + n + " teams)"); + return; + } + List> snapshot = new ArrayList<>(n); + for (TeamState team : teams) { + snapshot.add(new LinkedHashSet<>(team.players())); + team.players().clear(); + } + List moves = new ArrayList<>(); + for (int i = 0; i < n; i++) { + TeamState source = teams.get(i); + TeamState target = teams.get((i + shift) % n); + for (String playerId : snapshot.get(i)) { + target.players().add(playerId); + PlayerMinigameState player = rt.get().players().get(playerId); + if (player != null) { + player.teamId(target.teamId()); + if (clearCheckpoints) player.checkpointId(null); + } + moves.add(displayName(playerId) + " " + source.teamId() + " -> " + target.teamId()); + } + } + if (respawn) { + MinigameServices services = services(); + if (services != null) { + for (TeamState team : teams) { + for (String playerId : team.players()) { + PlayerMinigameState player = rt.get().players().get(playerId); + if (player == null || player.status() != PlayerStatus.ACTIVE) continue; + String dest = services.maps().respawnDestination(context.store(), rt.get(), player, null); + if (!dest.isBlank()) services.adapters().teleport(playerId, dest); + } + } + } + } + debugMessage(context, TYPE_ID, "FIRED swap rotation=" + shift + " teams=" + n + ": " + + (moves.isEmpty() ? "(no players)" : String.join(", ", moves))); + } + } + + public static final class SendMinigameMessage extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SendMinigameMessageEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SendMinigameMessage.class, SendMinigameMessage::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("MessageKey", Codec.STRING), + (interaction, value) -> interaction.messageKey = value, + interaction -> interaction.messageKey, + (interaction, parent) -> interaction.messageKey = parent.messageKey) + .add() + .appendInherited(new KeyedCodec<>("Target", new EnumCodec<>(SendMinigameMessageEffect.Target.class), false), + (interaction, value) -> interaction.target = value, + interaction -> interaction.target, + (interaction, parent) -> interaction.target = parent.target) + .add() + .build(); + + private String messageKey; + private SendMinigameMessageEffect.Target target = SendMinigameMessageEffect.Target.PLAYER; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + if (messageKey == null || messageKey.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no message key configured)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + SendMinigameMessageEffect.Target resolvedTarget = target != null ? target : SendMinigameMessageEffect.Target.PLAYER; + Message msg = Message.translation(messageKey); + switch (resolvedTarget) { + case PLAYER -> { + String id = resolvePlayerId(context, null); + if (id != null) services.adapters().sendMessage(id, msg); + } + case TEAM -> { + String id = resolvePlayerId(context, null); + if (id == null) break; + var rt = runtime(context); + if (rt.isEmpty()) break; + var triggeringPlayer = rt.get().players().get(id); + if (triggeringPlayer == null) break; + String team = triggeringPlayer.teamId(); + if (team == null || team.isBlank()) break; + rt.get().players().forEach((pid, p) -> { + if (team.equals(p.teamId())) services.adapters().sendMessage(pid, msg); + }); + } + case ALL -> { + var rt = runtime(context); + if (rt.isEmpty()) break; + rt.get().players().forEach((pid, p) -> { + if (p.status() == PlayerStatus.ACTIVE || p.status() == PlayerStatus.SPECTATOR) { + services.adapters().sendMessage(pid, msg); + } + }); + } + } + debugMessage(context, TYPE_ID, "FIRED key='" + messageKey + "' target=" + resolvedTarget); + } + } + + public static final class AssignTeam extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(AssignTeamEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(AssignTeam.class, AssignTeam::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("BalanceTeams", Codec.BOOLEAN, false), + (interaction, value) -> interaction.balanceTeams = value, + interaction -> interaction.balanceTeams, + (interaction, parent) -> interaction.balanceTeams = parent.balanceTeams) + .add() + .build(); + + private String playerId; + private boolean balanceTeams = false; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + if (balanceTeams) { + balanceTeams(context, rt.get()); + return; + } + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + if (teamId == null || teamId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no team id configured)"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + String prevTeam = player.teamId(); + if (prevTeam != null && !prevTeam.isBlank()) { + var old = rt.get().teams().get(prevTeam); + if (old != null) old.players().remove(id); + } + rt.get().teams().computeIfAbsent(teamId, k -> new TeamState(k, k, null)).players().add(id); + player.teamId(teamId); + debugMessage(context, TYPE_ID, "FIRED " + displayName(id) + " team " + + (prevTeam == null || prevTeam.isBlank() ? "none" : prevTeam) + " -> " + teamId); + } + + private void balanceTeams(@Nonnull MinigameActionContext context, @Nonnull MinigameRuntime runtime) { + if (runtime.players().isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED balance (no players in runtime)"); + return; + } + List teams = MinigameInteractions.balanceTeams(runtime); + if (teams.size() < 2) { + debugMessage(context, TYPE_ID, "SKIPPED balance (needs at least 2 teams)"); + return; + } + for (TeamState team : teams) team.players().clear(); + List players = runtime.players().keySet().stream().sorted().toList(); + List assignments = new ArrayList<>(players.size()); + for (int i = 0; i < players.size(); i++) { + String playerId = players.get(i); + TeamState team = teams.get(i % teams.size()); + runtime.players().get(playerId).teamId(team.teamId()); + team.players().add(playerId); + assignments.add(displayName(playerId) + " -> " + team.teamId()); + } + debugMessage(context, TYPE_ID, "FIRED balanced " + players.size() + " players across " + + teams.size() + " teams: " + String.join(", ", assignments)); + } + } + + public static final class SaveInventory extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SaveInventoryEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SaveInventory.class, SaveInventory::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .build(); + + private String playerId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + var snapshot = services.adapters().saveInventory(id); + player.savedInventory().clear(); + player.savedInventory().putAll(snapshot); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' saved " + snapshot.size() + " slots"); + } + } + + public static final class RestoreInventory extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(RestoreInventoryEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(RestoreInventory.class, RestoreInventory::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .build(); + + private String playerId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + if (player.savedInventory().isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' has no saved inventory)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + services.adapters().restoreInventory(id, player.savedInventory()); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' restored " + player.savedInventory().size() + " slots"); + } + } + + public static final class ClearInventory extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ClearInventoryEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ClearInventory.class, ClearInventory::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .build(); + + private String playerId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var services = services(); + if (services == null) { + debugMessage(context, TYPE_ID, "SKIPPED (services unavailable)"); + return; + } + services.adapters().clearInventory(id); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "'"); + } + } + + public static final class SetObjectiveStatus extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(SetObjectiveStatusEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(SetObjectiveStatus.class, SetObjectiveStatus::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("ObjectiveId", Codec.STRING), + (interaction, value) -> interaction.objectiveId = value, + interaction -> interaction.objectiveId, + (interaction, parent) -> interaction.objectiveId = parent.objectiveId) + .add() + .appendInherited(new KeyedCodec<>("Status", new EnumCodec<>(ObjectiveStatus.class)), + (interaction, value) -> interaction.status = value, + interaction -> interaction.status, + (interaction, parent) -> interaction.status = parent.status) + .add() + .build(); + + private String objectiveId; + private ObjectiveStatus status = ObjectiveStatus.ACTIVE; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + if (objectiveId == null || objectiveId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no objective id configured)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + ObjectiveStatus target = status != null ? status : ObjectiveStatus.ACTIVE; + var objective = rt.get().objectives().computeIfAbsent(objectiveId, k -> new ObjectiveState(k, k, "generic")); + ObjectiveStatus prev = objective.status(); + objective.status(target); + if (target == ObjectiveStatus.COMPLETE && prev != ObjectiveStatus.COMPLETE) { + var services = services(); + if (services != null) { + services.runtime().dispatch(rt.get(), "on_objective_complete", Map.of("objective_id", objectiveId)); + } + } + debugMessage(context, TYPE_ID, "FIRED objective='" + objectiveId + "' status=" + prev + "->" + target); + } + } + + public static final class ModifyObjectiveProgress extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(ModifyObjectiveProgressEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(ModifyObjectiveProgress.class, ModifyObjectiveProgress::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("ObjectiveId", Codec.STRING), + (interaction, value) -> interaction.objectiveId = value, + interaction -> interaction.objectiveId, + (interaction, parent) -> interaction.objectiveId = parent.objectiveId) + .add() + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .appendInherited(new KeyedCodec<>("Amount", Codec.INTEGER), + (interaction, value) -> interaction.amount = value, + interaction -> interaction.amount, + (interaction, parent) -> interaction.amount = parent.amount) + .add() + .build(); + + private String objectiveId; + private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD; + private int amount = 1; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + if (objectiveId == null || objectiveId.isBlank()) { + debugMessage(context, TYPE_ID, "SKIPPED (no objective id configured)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var objective = rt.get().objectives().computeIfAbsent(objectiveId, k -> new ObjectiveState(k, k, "generic")); + int prev = objective.progress(); + int next = Math.max(0, ModifyScoreEffect.apply(prev, operation, amount)); + objective.progress(next); + if (next >= objective.requiredProgress() && objective.status() == ObjectiveStatus.ACTIVE) { + objective.status(ObjectiveStatus.COMPLETE); + var services = services(); + if (services != null) { + services.runtime().dispatch(rt.get(), "on_objective_complete", + Map.of("objective_id", objectiveId, "progress", next)); + } + } + debugMessage(context, TYPE_ID, "FIRED objective='" + objectiveId + "' progress=" + prev + "->" + next + + "/" + objective.requiredProgress()); + } + } + + public static final class OpenMinigameQueueUI extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(OpenMinigameQueueUIEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(OpenMinigameQueueUI.class, OpenMinigameQueueUI::new, MINIGAME_INTERACTION_BASE_CODEC).build(); + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + MinigameQueueUIService queueUIService = MinigameCorePlugin.getQueueUIService(); + PlayerRef player = context.store().getComponent(context.entityRef(), PlayerRef.getComponentType()); + if (queueUIService == null || player == null) return; + String resolvedMinigameId = resolvedMinigameId(context); + String resolvedMapId = clean(context.tagValue(MinigameMapDiscoveryService.TAG_MAP_ID)); + MinigameQueueUIService.UIQueueMode mode = resolvedMinigameId.isBlank() + ? MinigameQueueUIService.UIQueueMode.GLOBAL + : resolvedMapId.isBlank() + ? MinigameQueueUIService.UIQueueMode.SINGLE + : MinigameQueueUIService.UIQueueMode.LOCAL; + queueUIService.requestOpen(player, mode, resolvedMinigameId, resolvedMapId); + } + } + + public static final class StartPlayerTimer extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StartPlayerTimerEffect.TYPE_ID); + private static final String VAR_TIMER_START = "$timer_start_ms"; + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StartPlayerTimer.class, StartPlayerTimer::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .build(); + + private String playerId; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + player.variables().put(VAR_TIMER_START, System.currentTimeMillis()); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' timer started"); + } + } + + public static final class StopPlayerTimer extends MinigameRuntimeInteraction { + public static final String TYPE_ID = interactionId(StopPlayerTimerEffect.TYPE_ID); + + @Nonnull + public static final BuilderCodec CODEC = + BuilderCodec.builder(StopPlayerTimer.class, StopPlayerTimer::new, MINIGAME_INTERACTION_BASE_CODEC) + .appendInherited(new KeyedCodec<>("PlayerId", Codec.STRING, false), + (interaction, value) -> interaction.playerId = value, + interaction -> interaction.playerId, + (interaction, parent) -> interaction.playerId = parent.playerId) + .add() + .appendInherited(new KeyedCodec<>("Operation", new EnumCodec<>(StopPlayerTimerEffect.Operation.class), false), + (interaction, value) -> interaction.operation = value, + interaction -> interaction.operation, + (interaction, parent) -> interaction.operation = parent.operation) + .add() + .build(); + + private String playerId; + private StopPlayerTimerEffect.Operation operation = StopPlayerTimerEffect.Operation.SET; + + @Override + protected void execute(@Nonnull MinigameActionContext context) { + String id = resolvePlayerId(context, playerId); + if (id == null) { + debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)"); + return; + } + var rt = runtime(context); + if (rt.isEmpty()) { + debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')"); + return; + } + var player = rt.get().players().get(id); + if (player == null) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)"); + return; + } + Object startObj = player.variables().get(StartPlayerTimer.VAR_TIMER_START); + if (!(startObj instanceof Long startMs)) { + debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' timer was never started)"); + return; + } + long elapsedMs = System.currentTimeMillis() - startMs; + int lap = (int) Math.min(elapsedMs / 10L, Integer.MAX_VALUE); + player.variables().remove(StartPlayerTimer.VAR_TIMER_START); + StopPlayerTimerEffect.Operation op = operation != null ? operation : StopPlayerTimerEffect.Operation.SET; + int prev = player.score(); + int next = switch (op) { + case SET -> lap; + case ADD -> prev + lap; + case BEST -> (prev <= 0 || lap < prev) ? lap : prev; + }; + player.score(next); + debugMessage(context, TYPE_ID, "FIRED player='" + id + "' op=" + op + + " lap=" + StopPlayerTimerEffect.formatPlayerTime(lap) + + " score=" + StopPlayerTimerEffect.formatPlayerTime(next)); + } + } + + @Nonnull + private static List resolvePlayerTargets( + @Nonnull MinigameActionContext context, + @Nonnull MinigameRuntime runtime, + @Nullable ModifyScoreEffect.TargetSource configuredSource + ) { + ModifyScoreEffect.TargetSource source = configuredSource != null ? configuredSource : ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER; + if (source == ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER) { + String playerId = MinigameRuntimeInteraction.resolvePlayerId(context, null); + return playerId != null ? List.of(playerId) : List.of(); + } + String teamId = resolveTeamId(context, runtime, source); + if (teamId == null || teamId.isBlank()) { + return List.of(); + } + var team = runtime.teams().get(teamId); + if (team != null) { + return new ArrayList<>(team.players()); + } + return runtime.players().entrySet().stream() + .filter(entry -> teamId.equals(entry.getValue().teamId())) + .map(Map.Entry::getKey) + .toList(); + } + + @Nullable + private static String resolveTeamId( + @Nonnull MinigameActionContext context, + @Nonnull MinigameRuntime runtime, + @Nullable ModifyScoreEffect.TargetSource configuredSource + ) { + ModifyScoreEffect.TargetSource source = configuredSource != null ? configuredSource : ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER; + if (source == ModifyScoreEffect.TargetSource.TAG_TEAM_ID) { + return clean(context.tagValue(MinigameMapDiscoveryService.TAG_TEAM_ID)); + } + String playerId = MinigameRuntimeInteraction.resolvePlayerId(context, null); + PlayerMinigameState player = playerId != null ? runtime.players().get(playerId) : null; + return player != null ? clean(player.teamId()) : ""; + } + + @Nonnull + private static List balanceTeams(@Nonnull MinigameRuntime runtime) { + List teams = new ArrayList<>(runtime.teams().values()); + if (teams.isEmpty()) { + int count = Math.max(0, runtime.definition().getTeamCount()); + if (count == 0) { + int perTeam = runtime.definition().getPlayersPerTeam(); + if (perTeam > 0) { + count = (int) Math.ceil((double) runtime.players().size() / perTeam); + } + } + for (int i = 1; i <= count; i++) { + String id = "team" + i; + teams.add(runtime.teams().computeIfAbsent(id, key -> new TeamState(key, key, null))); + } + } + teams.sort(Comparator.comparing(TeamState::teamId)); + return teams; + } + + private static int number(@Nullable Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value == null) { + return 0; + } + try { + return Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ignored) { + return 0; + } + } + + @Nonnull + private static String clean(@Nullable String value) { + return value != null && !value.isBlank() ? value.trim() : ""; + } +} diff --git a/src/main/java/net/kewwbec/minigames/interaction/MinigameRuntimeInteraction.java b/src/main/java/net/kewwbec/minigames/interaction/MinigameRuntimeInteraction.java new file mode 100644 index 0000000..1656b39 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/interaction/MinigameRuntimeInteraction.java @@ -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 MINIGAME_INTERACTION_BASE_CODEC = + BuilderCodec.abstractBuilder(MinigameRuntimeInteraction.class, SimpleInstantInteraction.CODEC) + .appendInherited( + new KeyedCodec<>("MinigameId", Codec.STRING, false), + (interaction, value) -> interaction.minigameId = value, + interaction -> interaction.minigameId, + (interaction, parent) -> interaction.minigameId = parent.minigameId + ) + .add() + .appendInherited( + new KeyedCodec<>("MapId", Codec.STRING, false), + (interaction, value) -> interaction.mapId = value, + interaction -> interaction.mapId, + (interaction, parent) -> interaction.mapId = parent.mapId + ) + .add() + .appendInherited( + new KeyedCodec<>("VariantId", Codec.STRING, false), + (interaction, value) -> interaction.variantId = value, + interaction -> interaction.variantId, + (interaction, parent) -> interaction.variantId = parent.variantId + ) + .add() + .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 commandBuffer = context.getCommandBuffer(); + Ref entityRef = context.getEntity(); + if (commandBuffer == null || entityRef == null || !entityRef.isValid() || commandBuffer.getExternalData() == null) { + return null; + } + Store store = commandBuffer.getExternalData().getStore(); + if (store == null) { + return null; + } + return MinigameActionContext.fromInteraction(entityRef, store, minigameId, mapId, variantId, teamId); + } + + @Nonnull + protected Optional 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); + } +} diff --git a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java index 7a282d2..28798bd 100644 --- a/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java +++ b/src/main/java/net/kewwbec/minigames/stats/MinigameStatsService.java @@ -86,6 +86,29 @@ public final class MinigameStatsService { return sessions.get(runtimeId); } + @Nonnull + public List 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 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) { diff --git a/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java b/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java index 50987f3..9a09051 100644 --- a/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java +++ b/src/main/java/net/kewwbec/minigames/stats/db/MinigameStatsRepository.java @@ -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 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 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 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 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 + ) { + } } diff --git a/src/main/java/net/kewwbec/minigames/system/MinigameLeaderboardUISystem.java b/src/main/java/net/kewwbec/minigames/system/MinigameLeaderboardUISystem.java new file mode 100644 index 0000000..573b3c8 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/system/MinigameLeaderboardUISystem.java @@ -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 { + 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 store) { + try { + service.openQueued(store); + } catch (Throwable e) { + LOGGER.atWarning().log("Leaderboard UI tick failed: %s: %s", + e.getClass().getSimpleName(), e.getMessage()); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameLeaderboardUIService.java b/src/main/java/net/kewwbec/minigames/ui/MinigameLeaderboardUIService.java new file mode 100644 index 0000000..1755859 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameLeaderboardUIService.java @@ -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 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 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 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 store, + @Nullable TriggerVolumeManager manager + ) { + List 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 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 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 values, String selected, java.util.function.Consumer 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 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 valuesFor(@Nullable TriggerVolumeManager manager, String minigameId, String dimension) { + Set 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 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 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 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) {} +} diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java index 395c7ff..4d5ff6b 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/MinigameRuntimeEffect.java @@ -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 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 : ""); + return runtime(actionContext(context)); + } - String playerId = resolvePlayerId(context, null); - if (playerId != null) { - Optional 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()) { - return Optional.empty(); - } - return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId()) - .or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst()); + @Nonnull + protected Optional 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(); - } - 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 : ""; + return resolvedMinigameId(actionContext(context)); + } + + @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); } } diff --git a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyScoreEffect.java b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyScoreEffect.java index ce42913..10a1180 100644 --- a/src/main/java/net/kewwbec/minigames/volume/effect/ModifyScoreEffect.java +++ b/src/main/java/net/kewwbec/minigames/volume/effect/ModifyScoreEffect.java @@ -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; diff --git a/src/main/resources/Server/Languages/en-US/minigames.lang b/src/main/resources/Server/Languages/en-US/minigames.lang index 557f4b4..785dd14 100644 --- a/src/main/resources/Server/Languages/en-US/minigames.lang +++ b/src/main/resources/Server/Languages/en-US/minigames.lang @@ -38,6 +38,8 @@ command.spec.leave.usage = /minigame leave command.spec.leave.description = Leave the current minigame. command.spec.spectate.usage = /minigame spectate 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 command.spec.volume.create.description = Create a minigame volume. command.spec.volume.link.usage = /minigame volume link @@ -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. diff --git a/src/test/java/net/kewwbec/minigames/interaction/MinigameInteractionsTest.java b/src/test/java/net/kewwbec/minigames/interaction/MinigameInteractionsTest.java new file mode 100644 index 0000000..50ef437 --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/interaction/MinigameInteractionsTest.java @@ -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 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 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); + } +}