From ca11bbd4c060562d6454b86e287524fb0c293be5 Mon Sep 17 00:00:00 2001 From: Dakroach Date: Sun, 21 Jun 2026 13:04:16 -0700 Subject: [PATCH] Add new minigame volume templates and corresponding tests - Introduced various minigame volume templates including map vote pad, NPC spawn, objective progress, player activation spawn, race, score zone, solo arena, team arena, team assignment pad, timed heal pack, and waiting area. - Each template includes specific configurations for positions, shapes, effects, and tags relevant to their functionality. - Implemented unit tests for minigame volume template export and spawn functionalities to ensure correct behavior and tag rewriting. - Added tests for minigame map discovery service to validate player respawn logic based on team assignments. - Enhanced minigame queue service tests to cover player join midgame scenarios. - Created a data store test for saving and loading player defaults in the minigame template builder. --- docs/commands.md | 43 +++ docs/events-conditions-effects.md | 7 +- docs/tags.md | 20 +- docs/volumes.md | 60 ++- .../kewwbec/minigames/MinigameCorePlugin.java | 40 +- .../command/MinigameVolumeTemplateExport.java | 159 ++++++++ .../command/MinigameVolumeTemplateSpawn.java | 208 +++++++++++ .../TriggerVolumeMinigameTemplateCommand.java | 97 +++++ ...iggerVolumeMinigameTemplatesUICommand.java | 50 +++ .../TriggerVolumeTemplateGroupCommand.java | 73 ++++ .../model/MinigameVolumeTemplate.java | 200 ++++++++++ .../service/MinigameMapDiscoveryService.java | 89 ++++- .../system/MinigameKillScoreSystem.java | 4 + .../ui/MinigameTemplateBuilderDataStore.java | 68 ++++ .../ui/MinigameTemplateBuilderUIService.java | 343 ++++++++++++++++++ .../Server/Languages/en-US/server.lang | 25 ++ .../capture_point.json | 44 +++ .../MinigameVolumeTemplates/checkpoint.json | 33 ++ .../core_lifecycle.json | 109 ++++++ .../MinigameVolumeTemplates/deathzone.json | 37 ++ .../MinigameVolumeTemplates/item_spawn.json | 39 ++ .../join_late_spectator.json | 59 +++ .../MinigameVolumeTemplates/map_vote_pad.json | 28 ++ .../MinigameVolumeTemplates/npc_spawn.json | 40 ++ .../objective_progress.json | 34 ++ .../player_activation_spawn.json | 36 ++ .../Server/MinigameVolumeTemplates/race.json | 101 ++++++ .../MinigameVolumeTemplates/score_zone.json | 34 ++ .../MinigameVolumeTemplates/solo_arena.json | 61 ++++ .../MinigameVolumeTemplates/team_arena.json | 94 +++++ .../team_assignment_pad.json | 29 ++ .../timed_heal_pack.json | 39 ++ .../MinigameVolumeTemplates/waiting_area.json | 89 +++++ .../MinigameVolumeTemplateExportTest.java | 38 ++ .../MinigameVolumeTemplateSpawnTest.java | 105 ++++++ .../MinigameMapDiscoveryServiceTest.java | 99 +++++ .../service/MinigameQueueServiceTest.java | 10 + .../MinigameTemplateBuilderDataStoreTest.java | 34 ++ 38 files changed, 2655 insertions(+), 23 deletions(-) create mode 100644 src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExport.java create mode 100644 src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawn.java create mode 100644 src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplateCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplatesUICommand.java create mode 100644 src/main/java/net/kewwbec/minigames/command/TriggerVolumeTemplateGroupCommand.java create mode 100644 src/main/java/net/kewwbec/minigames/model/MinigameVolumeTemplate.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStore.java create mode 100644 src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderUIService.java create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/capture_point.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/checkpoint.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/core_lifecycle.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/deathzone.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/item_spawn.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/join_late_spectator.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/map_vote_pad.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/npc_spawn.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/objective_progress.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/player_activation_spawn.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/race.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/score_zone.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/solo_arena.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/team_arena.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/team_assignment_pad.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/timed_heal_pack.json create mode 100644 src/main/resources/Server/MinigameVolumeTemplates/waiting_area.json create mode 100644 src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExportTest.java create mode 100644 src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawnTest.java create mode 100644 src/test/java/net/kewwbec/minigames/service/MinigameMapDiscoveryServiceTest.java create mode 100644 src/test/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStoreTest.java diff --git a/docs/commands.md b/docs/commands.md index 02a66aa..02b5448 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -109,3 +109,46 @@ The old custom minigame volume system was removed. Use Hytale's trigger-volume e volume placement and attach the minigame trigger effects and conditions there. `/triggervolume clone` and `/triggervolume clonegroup` (injected by this plugin) help duplicate configured volumes between maps. + +### `/triggervolume minigametemplate ` + +Spawns an asset-backed minigame trigger-volume template as normal trigger volumes +relative to the command sender's position. + +Supported forms: + +```text +/triggervolume minigametemplate core_lifecycle --MinigameId=Fishing --MapId=Lake +/triggervolume minigametemplate team_arena --MinigameId=Battle--MapId=Castle--VariantId=Night +``` + +`MinigameId` and `MapId` are required. `VariantId` is optional. Spawned volume names +replace the template prefix, so `Template_MainArena` becomes +`Fishing_Lake_MainArena` or `Battle_Castle_Night_MainArena`. + +The command fails before creating anything when any target volume or group name already +exists. Spawned volumes are normal Hytale trigger volumes and can be moved, resized, and +edited in the trigger-volume editor. + +### `/triggervolume minigametemplates` + +Opens the builder UI for template spawning. Builders can enter `MinigameId`, `MapId`, +and optional `VariantId` once, then click any loaded template to spawn it at their +current position. The variables are saved per player under the plugin data directory and +are reused until the builder changes them. + +### `/triggervolume templategroup ` + +Exports an existing trigger-volume group in the world as a new `MinigameVolumeTemplate` +asset in `/Server/MinigameVolumeTemplates/.json`. + +The group origin becomes the template origin. Member volumes are stored at positions +relative to that origin, their ids are rewritten to `Template_`, and +`MinigameId`, `MapId`, and `VariantId` tags are rewritten to `Template`. + +```text +/triggervolume templategroup MyAssetPack Battle_Castle core_battle "Core Battle" +``` + +The new template appears after the next asset reload/server restart and can be spawned +with `/triggervolume minigametemplate` or the template builder UI. diff --git a/docs/events-conditions-effects.md b/docs/events-conditions-effects.md index e87917d..25dfc38 100644 --- a/docs/events-conditions-effects.md +++ b/docs/events-conditions-effects.md @@ -125,14 +125,15 @@ Use an explicit `PlayerId` only when the trigger is not naturally tied to the pl 1. The effect's explicit `DestinationId`. 2. The player's stored checkpoint from `SetSpawnPoint`. 3. A random team spawn volume matching the player's `TeamId`. +4. A random shared player spawn volume. -Team spawn volumes are normal Hytale trigger volumes tagged with `MinigameId`, `MapId`, optional `VariantId`, `SpawnRole=TeamSpawn`, and `TeamId=`. +Shared spawn volumes are normal Hytale trigger volumes tagged with `MinigameId`, `MapId`, optional `VariantId`, and `SpawnRole=PlayerSpawn`. Team spawn volumes add `SpawnRole=TeamSpawn` and `TeamId=`. Runtime chooses a matching team spawn first, then falls back to a shared `PlayerSpawn`. Player deaths during a minigame use the same resolution order automatically. `RespawnPlayer.DestinationId` accepts coordinate strings, such as `100,64,200` or `world_name,100,64,200`; `SetSpawnPoint` exposes separate `X`, `Y`, and `Z` fields and stores them as the player's checkpoint. -`RespawnAllPlayers` applies the same resolution to every player at once, making it the building block for resetting the arena between rounds. Because checkpoints (step 2) take priority over team spawns (step 3), set `ClearCheckpoints=true` when you want players sent back to their start line or team spawn rather than wherever they last checkpointed. A typical round transition chains `SetRound` → `ResetScores` (optional) → `SwapTeams` (for alternating-sides maps) → `RespawnAllPlayers`. +`RespawnAllPlayers` applies the same resolution to every player at once, making it the building block for resetting the arena between rounds. Because checkpoints (step 2) take priority over tagged spawn volumes (steps 3-4), set `ClearCheckpoints=true` when you want players sent back to their start line or team spawn rather than wherever they last checkpointed. A typical round transition chains `SetRound` → `ResetScores` (optional) → `SwapTeams` (for alternating-sides maps) → `RespawnAllPlayers`. -`SwapTeams` rotates team membership for maps where sides alternate each round (attack/defend). It clears checkpoints by default so the follow-up respawn resolves the new team's spawn; this relies on each side having team spawn volumes tagged as above. With its `Respawn` flag left on, it moves players to their new side immediately and no separate `RespawnAllPlayers` is needed. +`SwapTeams` rotates team membership for maps where sides alternate each round (attack/defend). It clears checkpoints by default so the follow-up respawn resolves the new team's spawn; this relies on each side having team spawn volumes tagged as above, with shared `PlayerSpawn` volumes as fallback. With its `Respawn` flag left on, it moves players to their new side immediately and no separate `RespawnAllPlayers` is needed. ## Item Spawners diff --git a/docs/tags.md b/docs/tags.md index 2fdf408..08a3606 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -1,6 +1,6 @@ # Trigger Volume Tags -core-minigames uses normal Hytale trigger-volume tags for map discovery, runtime routing, team spawns, kill scoring, and runtime signals. Tag keys and values are case-sensitive. +core-minigames uses normal Hytale trigger-volume tags for map discovery, runtime routing, player/team spawns, kill scoring, and runtime signals. Tag keys and values are case-sensitive. ## Map And Runtime Tags @@ -34,17 +34,25 @@ Overlapping volumes are allowed. Volumes with the same `MinigameId`, `MapId`, an ## Spawn Tags -Team spawn volumes are normal trigger volumes used as teleport destinations by `RespawnPlayer`. +Spawn volumes are normal trigger volumes used as random teleport destinations by `RespawnPlayer`, `RespawnAllPlayers`, and automatic death respawns. | Tag | Required | Values | Used By | |-----|----------|--------|---------| | `MinigameId` | Recommended | Minigame ID | Filters spawns to the current runtime's minigame | | `MapId` | Recommended | Map ID | Filters spawns to the current runtime's map | | `VariantId` | No | Variant ID | Filters spawns to the current runtime's variant when present | -| `SpawnRole` | Yes | `TeamSpawn` | Marks the volume as a team spawn candidate | -| `TeamId` | Yes | Team ID, such as `red` or `blue` | Selects spawns for the player's assigned team; also used by `ModifyScore`/`ModifyLives` when `TargetSource=TAG_TEAM_ID` | +| `SpawnRole` | Yes | `PlayerSpawn` or `TeamSpawn` | Marks the volume as a spawn candidate | +| `TeamId` | Only for team-specific spawns | Team ID, such as `red` or `blue` | Selects spawns for the player's assigned team; also used by `ModifyScore`/`ModifyLives` when `TargetSource=TAG_TEAM_ID` | -Example: +All-player spawn example: + +```text +MinigameId=FFA +MapId=Arena +SpawnRole=PlayerSpawn +``` + +Team spawn example: ```text MinigameId=KOTH @@ -53,7 +61,7 @@ SpawnRole=TeamSpawn TeamId=red ``` -`RespawnPlayer` chooses a random matching team spawn when no explicit `DestinationId` and no player checkpoint are available. +`RespawnPlayer` first chooses a random matching team spawn when the player has a `TeamId`. If no matching team spawn exists, or the player has no team, it chooses a random `PlayerSpawn` volume without `TeamId`. `ModifyScore` and `ModifyLives` can also read `TeamId` from any trigger volume, not only team spawns. This lets one volume award points to the triggering player's team while removing lives/respawns from the team named in that volume's `TeamId` tag. diff --git a/docs/volumes.md b/docs/volumes.md index 590dd62..51a15ef 100644 --- a/docs/volumes.md +++ b/docs/volumes.md @@ -22,6 +22,50 @@ Overlapping volumes are valid. Runtime routing uses the selected `MinigameId`, ` ## Common Patterns +### Template Spawning + +Use `/triggervolume minigametemplate` to place a starter set of normal Hytale trigger +volumes at your current position: + +```text +/triggervolume minigametemplate core_lifecycle --MinigameId=MyGame --MapId=Arena01 +``` + +Use `/triggervolume minigametemplates` for the builder UI version. It remembers the +builder's `MinigameId`, `MapId`, and optional `VariantId`, then lists every loaded +template as a one-click spawn action. + +Use `/triggervolume templategroup ` to turn a +configured trigger-volume group in the current world into a reusable template asset. +The exporter stores member positions relative to the group origin and rewrites +minigame/map/variant tags to `Template`. + +Templates are assets in `Server/MinigameVolumeTemplates/` and can be edited in the +asset editor. Template volume ids should use `Template_{VolumePurpose}`. When spawned, +`Template` is replaced with `MinigameId_MapId` plus optional `VariantId`, and tags with +`MinigameId=Template`, `MapId=Template`, and `VariantId=Template` are replaced with the +provided command values. + +Built-in templates: + +- `core_lifecycle` +- `solo_arena` +- `team_arena` +- `race` +- `capture_point` +- `item_spawn` +- `npc_spawn` +- `waiting_area` +- `join_late_spectator` +- `timed_heal_pack` +- `checkpoint` +- `deathzone` +- `map_vote_pad` +- `score_zone` +- `objective_progress` +- `team_assignment_pad` +- `player_activation_spawn` + ### Start Zone Use a normal trigger volume at an entrance, sign, NPC, or lobby pad. @@ -70,11 +114,18 @@ Use Hytale's normal volume behavior for the area and guard attached effects with - Condition: `MinigamePhase` - Field: `Phase = ACTIVE` -### Team Spawns +### Player And Team Spawns Use normal trigger volumes as spawn markers. The volume position is the teleport destination. -Tag each team spawn volume with: +Tag shared all-player spawn volumes with: + +- `MinigameId=` +- `MapId=` +- optional `VariantId=` +- `SpawnRole=PlayerSpawn` + +Tag each team-specific spawn volume with: - `MinigameId=` - `MapId=` @@ -87,10 +138,11 @@ Tag each team spawn volume with: 1. The effect's explicit `DestinationId`. 2. The player's stored checkpoint from `SetSpawnPoint`. 3. A random matching team spawn volume for the player's `TeamId`. +4. A random shared `PlayerSpawn` volume with no `TeamId`. -This means checkpoint-based games can still override spawns per player, while team arena games can rely on tagged team spawn volumes. +This means checkpoint-based games can still override spawns per player, team arena games can rely on tagged team spawn volumes, and solo/FFA games can rely on a pool of shared `PlayerSpawn` volumes. -Player deaths during a minigame automatically use the same destination order. `RespawnPlayer.DestinationId` accepts coordinate strings, such as `100,64,200` or `world_name,100,64,200`; `SetSpawnPoint` uses separate `X`, `Y`, and `Z` fields. Team-spawn fallback is resolved from tagged spawn volumes. +Player deaths during a minigame automatically use the same destination order. `RespawnPlayer.DestinationId` accepts coordinate strings, such as `100,64,200` or `world_name,100,64,200`; `SetSpawnPoint` uses separate `X`, `Y`, and `Z` fields. Spawn fallback is resolved from tagged spawn volumes. ### Counters diff --git a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java index 829d1f9..3d29712 100644 --- a/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java +++ b/src/main/java/net/kewwbec/minigames/MinigameCorePlugin.java @@ -18,6 +18,7 @@ import com.hypixel.hytale.server.npc.NPCPlugin; import net.kewwbec.minigames.adapter.HytaleServerAdapters; import net.kewwbec.minigames.command.MinigameCommand; import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService; import net.kewwbec.minigames.service.DefaultMinigameService; import net.kewwbec.minigames.service.MinigameMapDiscoveryService; @@ -44,6 +45,8 @@ import net.kewwbec.minigames.ui.MinigameHudSystem; import net.kewwbec.minigames.ui.MinigameMenuOpenSystem; import net.kewwbec.minigames.ui.MinigameMenuService; import net.kewwbec.minigames.ui.MinigameQueueUIService; +import net.kewwbec.minigames.ui.MinigameTemplateBuilderDataStore; +import net.kewwbec.minigames.ui.MinigameTemplateBuilderUIService; import net.kewwbec.minigames.system.MinigameQueueUISystem; import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect; @@ -104,6 +107,9 @@ import com.hypixel.hytale.server.core.io.adapter.PacketAdapters; import com.hypixel.hytale.server.core.io.adapter.PacketFilter; import net.kewwbec.minigames.command.TriggerVolumeCloneCommand; import net.kewwbec.minigames.command.TriggerVolumeCloneGroupCommand; +import net.kewwbec.minigames.command.TriggerVolumeMinigameTemplateCommand; +import net.kewwbec.minigames.command.TriggerVolumeMinigameTemplatesUICommand; +import net.kewwbec.minigames.command.TriggerVolumeTemplateGroupCommand; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -116,6 +122,7 @@ public final class MinigameCorePlugin extends JavaPlugin { private static MinigameCorePlugin instance; private MinigameServices services; private MinigameQueueUIService queueUIService; + private MinigameTemplateBuilderUIService templateBuilderUIService; private StatsDatabase statsDatabase; private PacketFilter duplicatePacketFilter; private ComponentType lockMountComponentType; @@ -146,6 +153,14 @@ public final class MinigameCorePlugin extends JavaPlugin { .loadsAfter(TriggerEffectAsset.class) .build() ); + AssetRegistry.register( + HytaleAssetStore.builder(MinigameVolumeTemplate.class, new DefaultAssetMap()) + .setPath("MinigameVolumeTemplates") + .setCodec(MinigameVolumeTemplate.CODEC) + .setKeyFunction(MinigameVolumeTemplate::getId) + .loadsAfter(TriggerEffectAsset.class) + .build() + ); var runtimeService = new DefaultMinigameRuntimeService(); var minigameService = new DefaultMinigameService(runtimeService); @@ -175,6 +190,9 @@ public final class MinigameCorePlugin extends JavaPlugin { this.queueUIService = new MinigameQueueUIService(services); this.queueUIService.setPlayerAdapter(adapters); getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService)); + this.templateBuilderUIService = + new MinigameTemplateBuilderUIService(new MinigameTemplateBuilderDataStore(getDataDirectory())); + this.templateBuilderUIService.setPlayerAdapter(adapters); // Definition-flag enforcement (AllowPvp / AllowBlockBreaking / AllowBlockPlacing) getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.PvpGuard()); @@ -244,6 +262,11 @@ public final class MinigameCorePlugin extends JavaPlugin { return instance != null ? instance.queueUIService : null; } + @Nullable + public static MinigameTemplateBuilderUIService getTemplateBuilderUIService() { + return instance != null ? instance.templateBuilderUIService : null; + } + @Nullable public static ComponentType getLockMountComponentType() { MinigameCorePlugin plugin = instance; @@ -271,13 +294,25 @@ public final class MinigameCorePlugin extends JavaPlugin { TriggerVolumeCloneGroupCommand cloneGroupCommand = new TriggerVolumeCloneGroupCommand(); tvCommand.addSubCommand(cloneGroupCommand); cloneGroupCommand.completeRegistration(); + TriggerVolumeMinigameTemplateCommand templateCommand = new TriggerVolumeMinigameTemplateCommand(); + tvCommand.addSubCommand(templateCommand); + templateCommand.completeRegistration(); + TriggerVolumeTemplateGroupCommand templateGroupCommand = new TriggerVolumeTemplateGroupCommand(); + tvCommand.addSubCommand(templateGroupCommand); + templateGroupCommand.completeRegistration(); + if (templateBuilderUIService != null) { + TriggerVolumeMinigameTemplatesUICommand templatesUICommand = + new TriggerVolumeMinigameTemplatesUICommand(templateBuilderUIService); + tvCommand.addSubCommand(templatesUICommand); + templatesUICommand.completeRegistration(); + } hasBeenRegisteredField.set(tvCommand, true); - LOGGER.atInfo().log("Injected /triggervolume clone and clonegroup commands"); + LOGGER.atInfo().log("Injected /triggervolume clone, clonegroup, minigametemplate, templategroup, and minigametemplates commands"); } catch (Exception e) { // Reflection into CommandManager internals: breaks silently on server updates, // so make the health check unmissable for admins reading the log. LOGGER.at(Level.SEVERE).withCause(e).log( - "HEALTH CHECK FAILED: /triggervolume clone and clonegroup are UNAVAILABLE. " + "HEALTH CHECK FAILED: /triggervolume clone, clonegroup, minigametemplate, templategroup, and minigametemplates are UNAVAILABLE. " + "The server build likely changed CommandManager internals; core-minigames needs an update."); } } @@ -374,6 +409,7 @@ public final class MinigameCorePlugin extends JavaPlugin { } triggerVolumes.registerAssetSource("MinigameDefinition", () -> MinigameDefinition.getAssetMap().getAssetMap().keySet()); + triggerVolumes.registerAssetSource("MinigameVolumeTemplate", () -> MinigameVolumeTemplate.getAssetMap().getAssetMap().keySet()); triggerVolumes.registerAssetSource("NpcRole", () -> { var npcPlugin = NPCPlugin.get(); return npcPlugin != null ? npcPlugin.getRoleTemplateNames(true) : java.util.List.of(); diff --git a/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExport.java b/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExport.java new file mode 100644 index 0000000..4494b72 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExport.java @@ -0,0 +1,159 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.assetstore.AssetPack; +import com.hypixel.hytale.builtin.triggervolumes.manager.GroupEntry; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.server.core.asset.AssetModule; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; +import net.kewwbec.minigames.service.DefaultMinigameService; +import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class MinigameVolumeTemplateExport { + private static final String TEMPLATE_TOKEN = "Template"; + + private MinigameVolumeTemplateExport() { + } + + @Nonnull + public static ExportResult exportGroup( + @Nonnull TriggerVolumeManager manager, + @Nonnull String packName, + @Nonnull String groupName, + @Nonnull String templateId, + @Nonnull String name + ) { + if (!DefaultMinigameService.isValidId(templateId)) { + return ExportResult.failure("invalid_id", "", 0); + } + + GroupEntry group = manager.getGroup(groupName); + if (group == null) { + return ExportResult.failure("group_not_found", "", 0); + } + List members = manager.getGroupMembers(groupName); + if (members.isEmpty()) { + return ExportResult.failure("empty_group", "", 0); + } + + try { + AssetPack pack = resolvePack(packName); + MinigameVolumeTemplate template = buildTemplate(group, members, templateId, name); + Path relativePath = Path.of(templateId + ".json"); + MinigameVolumeTemplate.getAssetStore().writeAssetToDisk(pack, Map.of(relativePath, template)); + scrubTargetTypes(pack.getRoot().resolve("Server").resolve("MinigameVolumeTemplates").resolve(relativePath)); + return ExportResult.success(pack.getName(), members.size()); + } catch (IOException | RuntimeException e) { + return ExportResult.failure(e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(), "", 0); + } + } + + @Nonnull + private static MinigameVolumeTemplate buildTemplate( + @Nonnull GroupEntry group, + @Nonnull List members, + @Nonnull String templateId, + @Nonnull String name + ) { + MinigameVolumeTemplate.TemplateVolume[] templateVolumes = + new MinigameVolumeTemplate.TemplateVolume[members.size()]; + for (int i = 0; i < members.size(); i++) { + VolumeEntry member = members.get(i); + String templateVolumeId = templateVolumeId(member.getId(), group.getId()); + VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(member, templateVolumeId); + copy.setId(templateVolumeId); + copy.setWorldName(""); + copy.setGroupId(TEMPLATE_TOKEN); + copy.setPosition(new Vector3d(member.getPosition()).sub(group.getOrigin())); + copy.setTags(templateTags(copy.getRawTags())); + templateVolumes[i] = new MinigameVolumeTemplate.TemplateVolume(templateVolumeId, copy); + } + + return new MinigameVolumeTemplate( + templateId, + name, + "Exported from trigger-volume group '" + group.getId() + "'.", + TEMPLATE_TOKEN, + group.getColor(), + templateTags(group.getRawTags()), + templateVolumes + ); + } + + @Nonnull + static String templateVolumeId(@Nonnull String volumeId, @Nonnull String groupId) { + String purpose = volumeId; + if (!groupId.isBlank() && purpose.startsWith(groupId + "_")) { + purpose = purpose.substring(groupId.length() + 1); + } + if (purpose.startsWith(TEMPLATE_TOKEN + "_")) { + return purpose; + } + purpose = purpose.replaceAll("[^A-Za-z0-9_-]", "_"); + if (purpose.isBlank()) { + purpose = "Volume"; + } + return TEMPLATE_TOKEN + "_" + purpose; + } + + @Nonnull + static Map templateTags(@Nonnull Map source) { + Map tags = new HashMap<>(source); + rewriteTemplateTag(tags, "MinigameId"); + rewriteTemplateTag(tags, "MapId"); + rewriteTemplateTag(tags, "VariantId"); + tags.putIfAbsent("MinigameId", TEMPLATE_TOKEN); + tags.putIfAbsent("MapId", TEMPLATE_TOKEN); + return tags; + } + + private static void rewriteTemplateTag(@Nonnull Map tags, @Nonnull String key) { + if (tags.containsKey(key)) { + tags.put(key, TEMPLATE_TOKEN); + } + } + + @Nonnull + private static AssetPack resolvePack(@Nonnull String packName) throws IOException { + AssetPack pack = AssetModule.get().getAssetPack(packName); + if (pack == null) { + throw new IOException("Unknown asset pack: " + packName); + } + if (pack.isImmutable()) { + throw new IOException("Asset pack is immutable: " + pack.getName()); + } + return pack; + } + + private static void scrubTargetTypes(@Nonnull Path path) throws IOException { + if (!Files.isRegularFile(path)) { + return; + } + String text = Files.readString(path, StandardCharsets.UTF_8); + text = text.replaceAll("(?m)^\\s*\"TargetTypes\"\\s*:\\s*\\[[^\\]]*]\\s*,\\R", ""); + Files.writeString(path, text, StandardCharsets.UTF_8); + } + + public record ExportResult(boolean success, @Nonnull String reason, @Nonnull String packName, int volumeCount) { + @Nonnull + static ExportResult success(@Nonnull String packName, int volumeCount) { + return new ExportResult(true, "", packName, volumeCount); + } + + @Nonnull + static ExportResult failure(@Nonnull String reason, @Nonnull String packName, int volumeCount) { + return new ExportResult(false, reason, packName, volumeCount); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawn.java b/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawn.java new file mode 100644 index 0000000..d52fd99 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawn.java @@ -0,0 +1,208 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.builtin.triggervolumes.EntityTargetType; +import com.hypixel.hytale.builtin.triggervolumes.manager.GroupEntry; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; +import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem; +import org.joml.Vector3d; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class MinigameVolumeTemplateSpawn { + private static final Pattern FLAG_PATTERN = + Pattern.compile("--([A-Za-z][A-Za-z0-9]*)=([^\\s]*?)(?=--[A-Za-z][A-Za-z0-9]*=|\\s|$)"); + private static final String TEMPLATE_TOKEN = "Template"; + + private MinigameVolumeTemplateSpawn() { + } + + @Nonnull + public static TemplateArgs parseArgs(@Nonnull String raw) { + Map flags = new LinkedHashMap<>(); + Matcher matcher = FLAG_PATTERN.matcher(raw); + while (matcher.find()) { + flags.put(matcher.group(1).toLowerCase(Locale.ROOT), matcher.group(2)); + } + return new TemplateArgs( + clean(flags.get("minigameid")), + clean(flags.get("mapid")), + clean(flags.get("variantid")) + ); + } + + @Nonnull + public static String spawnedVolumeId(@Nonnull String templateVolumeId, @Nonnull TemplateArgs args) { + String purpose = templateVolumeId.startsWith(TEMPLATE_TOKEN + "_") + ? templateVolumeId.substring((TEMPLATE_TOKEN + "_").length()) + : templateVolumeId; + return spawnPrefix(args) + "_" + purpose; + } + + @Nonnull + public static String spawnPrefix(@Nonnull TemplateArgs args) { + String base = args.minigameId() + "_" + args.mapId(); + return args.variantId().isBlank() ? base : base + "_" + args.variantId(); + } + + @Nonnull + public static Map rewriteTags(@Nonnull Map source, @Nonnull TemplateArgs args) { + Map tags = new HashMap<>(source); + rewriteTag(tags, "MinigameId", args.minigameId()); + rewriteTag(tags, "MapId", args.mapId()); + if (args.variantId().isBlank()) { + if (TEMPLATE_TOKEN.equals(tags.get("VariantId"))) { + tags.remove("VariantId"); + } + } else { + rewriteTag(tags, "VariantId", args.variantId()); + } + return tags; + } + + @Nonnull + public static List plannedVolumeIds(@Nonnull MinigameVolumeTemplate template, @Nonnull TemplateArgs args) { + List ids = new ArrayList<>(); + for (MinigameVolumeTemplate.TemplateVolume templateVolume : template.getVolumes()) { + ids.add(spawnedVolumeId(templateVolume.getId(), args)); + } + return ids; + } + + @Nonnull + public static List conflictingVolumeIds( + @Nonnull MinigameVolumeTemplate template, + @Nonnull TemplateArgs args, + @Nonnull Predicate exists + ) { + List conflicts = new ArrayList<>(); + for (String id : plannedVolumeIds(template, args)) { + if (exists.test(id)) { + conflicts.add(id); + } + } + return conflicts; + } + + @Nonnull + public static SpawnResult spawn( + @Nonnull MinigameVolumeTemplate template, + @Nonnull TemplateArgs args, + @Nonnull TriggerVolumeManager manager, + @Nonnull String worldName, + @Nonnull Vector3d origin + ) { + if (!args.isValid()) { + return SpawnResult.failure("missing_required_ids", List.of()); + } + if (template.getVolumes().length == 0) { + return SpawnResult.failure("empty_template", List.of()); + } + + List conflicts = conflictingVolumeIds(template, args, manager::hasVolume); + String groupId = spawnedGroupId(template, args); + if (!groupId.isBlank() && manager.hasGroup(groupId)) { + conflicts.add(groupId); + } + if (!conflicts.isEmpty()) { + return SpawnResult.failure("conflict", conflicts); + } + + if (!groupId.isBlank()) { + GroupEntry group = new GroupEntry( + groupId, + worldName.toLowerCase(Locale.ROOT), + new Vector3d(origin), + new ArrayList<>(), + EnumSet.of(EntityTargetType.PLAYER), + true, + template.getGroupColor() + ); + Map groupTags = rewriteTags(template.getGroupTags(), args); + if (!groupTags.isEmpty()) { + group.setTags(groupTags); + } + manager.registerGroup(groupId, group); + } + + List spawned = new ArrayList<>(); + for (MinigameVolumeTemplate.TemplateVolume templateVolume : template.getVolumes()) { + String newId = spawnedVolumeId(templateVolume.getId(), args); + VolumeEntry copy = TriggerVolumeDuplicateSystem.duplicateEntry(templateVolume.getVolume(), newId); + copy.setId(newId); + copy.setWorldName(worldName.toLowerCase(Locale.ROOT)); + copy.setPosition(new Vector3d(origin).add(templateVolume.getVolume().getPosition())); + copy.setTags(rewriteTags(copy.getRawTags(), args)); + if (!groupId.isBlank()) { + copy.setGroupId(groupId); + } + manager.register(newId, copy); + if (!groupId.isBlank()) { + GroupEntry group = manager.getGroup(groupId); + if (group != null) { + group.addMember(newId); + } + } + manager.notifyViewersAdd(copy); + spawned.add(newId); + } + manager.markSpatialDirty(); + return SpawnResult.success(spawned); + } + + @Nonnull + private static String spawnedGroupId(@Nonnull MinigameVolumeTemplate template, @Nonnull TemplateArgs args) { + String configured = template.getGroupId(); + if (configured.isBlank()) { + return ""; + } + if (TEMPLATE_TOKEN.equals(configured)) { + return spawnPrefix(args); + } + if (configured.startsWith(TEMPLATE_TOKEN + "_")) { + return spawnPrefix(args) + configured.substring(TEMPLATE_TOKEN.length()); + } + return spawnPrefix(args) + "_" + configured; + } + + private static void rewriteTag(@Nonnull Map tags, @Nonnull String key, @Nonnull String replacement) { + if (TEMPLATE_TOKEN.equals(tags.get(key))) { + tags.put(key, replacement); + } + } + + @Nonnull + private static String clean(@Nullable String value) { + return value != null ? value.trim() : ""; + } + + public record TemplateArgs(@Nonnull String minigameId, @Nonnull String mapId, @Nonnull String variantId) { + public boolean isValid() { + return !minigameId.isBlank() && !mapId.isBlank(); + } + } + + public record SpawnResult(boolean success, @Nonnull String reason, @Nonnull List volumeIds) { + @Nonnull + public static SpawnResult success(@Nonnull List volumeIds) { + return new SpawnResult(true, "", List.copyOf(volumeIds)); + } + + @Nonnull + public static SpawnResult failure(@Nonnull String reason, @Nonnull List conflicts) { + return new SpawnResult(false, reason, List.copyOf(conflicts)); + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplateCommand.java b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplateCommand.java new file mode 100644 index 0000000..214b450 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplateCommand.java @@ -0,0 +1,97 @@ +package net.kewwbec.minigames.command; + +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.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; + +import javax.annotation.Nonnull; + +public final class TriggerVolumeMinigameTemplateCommand extends AbstractWorldCommand { + private final RequiredArg templateArg = + this.withRequiredArg("templateName", "server.commands.triggervolume.minigametemplate.templateName.desc", ArgTypes.STRING); + private final RequiredArg flagsArg = + this.withRequiredArg("flags", "server.commands.triggervolume.minigametemplate.flags.desc", ArgTypes.GREEDY_STRING); + + public TriggerVolumeMinigameTemplateCommand() { + super("minigametemplate", "server.commands.triggervolume.minigametemplate.desc"); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + return; + } + + Ref playerRef = context.senderAsPlayerRef(); + if (playerRef == null || !playerRef.isValid()) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.playerRequired")); + return; + } + + String templateName = templateArg.get(context); + MinigameVolumeTemplate template = MinigameVolumeTemplate.getAssetMap().getAsset(templateName); + if (template == null) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.notFound") + .param("name", templateName)); + return; + } + + MinigameVolumeTemplateSpawn.TemplateArgs args = MinigameVolumeTemplateSpawn.parseArgs(flagsArg.get(context)); + if (!args.isValid()) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.missingIds")); + return; + } + + TransformComponent transform = store.getComponent(playerRef, TransformComponent.getComponentType()); + if (transform == null) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.playerRequired")); + return; + } + + MinigameVolumeTemplateSpawn.SpawnResult result = MinigameVolumeTemplateSpawn.spawn( + template, + args, + manager, + world.getName(), + transform.getPosition() + ); + if (!result.success()) { + if ("conflict".equals(result.reason())) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.conflict") + .param("names", String.join(", ", result.volumeIds()))); + } else if ("empty_template".equals(result.reason())) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.empty") + .param("name", templateName)); + } else { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.missingIds")); + } + return; + } + + PlayerRef playerRefComponent = store.getComponent(playerRef, PlayerRef.getComponentType()); + if (playerRefComponent != null && !result.volumeIds().isEmpty()) { + manager.setPlayerSelection(playerRefComponent.getUuid(), result.volumeIds().get(0)); + } + + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.success") + .param("template", templateName) + .param("count", String.valueOf(result.volumeIds().size())) + .param("prefix", MinigameVolumeTemplateSpawn.spawnPrefix(args))); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplatesUICommand.java b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplatesUICommand.java new file mode 100644 index 0000000..e2b602e --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeMinigameTemplatesUICommand.java @@ -0,0 +1,50 @@ +package net.kewwbec.minigames.command; + +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.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import net.kewwbec.minigames.ui.MinigameTemplateBuilderUIService; + +import javax.annotation.Nonnull; + +public final class TriggerVolumeMinigameTemplatesUICommand extends AbstractWorldCommand { + private final MinigameTemplateBuilderUIService uiService; + + public TriggerVolumeMinigameTemplatesUICommand(@Nonnull MinigameTemplateBuilderUIService uiService) { + super("minigametemplates", "server.commands.triggervolume.minigametemplates.desc"); + this.uiService = uiService; + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + return; + } + + Ref playerEntity = context.senderAsPlayerRef(); + if (playerEntity == null || !playerEntity.isValid()) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.playerRequired")); + return; + } + + PlayerRef player = store.getComponent(playerEntity, PlayerRef.getComponentType()); + if (player == null) { + context.sendMessage(Message.translation("server.commands.triggervolume.minigametemplate.playerRequired")); + return; + } + + uiService.open(player, playerEntity, world, store, manager); + } +} diff --git a/src/main/java/net/kewwbec/minigames/command/TriggerVolumeTemplateGroupCommand.java b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeTemplateGroupCommand.java new file mode 100644 index 0000000..d6ef713 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/command/TriggerVolumeTemplateGroupCommand.java @@ -0,0 +1,73 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.component.Store; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.command.system.CommandContext; +import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredArg; +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes; +import com.hypixel.hytale.server.core.command.system.basecommands.AbstractWorldCommand; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; + +import javax.annotation.Nonnull; + +public final class TriggerVolumeTemplateGroupCommand extends AbstractWorldCommand { + private final RequiredArg packArg = + this.withRequiredArg("pack", "server.commands.triggervolume.templategroup.pack.desc", ArgTypes.STRING); + private final RequiredArg groupNameArg = + this.withRequiredArg("groupName", "server.commands.triggervolume.templategroup.groupName.desc", ArgTypes.STRING); + private final RequiredArg templateIdArg = + this.withRequiredArg("templateId", "server.commands.triggervolume.templategroup.templateId.desc", ArgTypes.STRING); + private final RequiredArg nameArg = + this.withRequiredArg("name", "server.commands.triggervolume.templategroup.name.desc", ArgTypes.GREEDY_STRING); + + public TriggerVolumeTemplateGroupCommand() { + super("templategroup", "server.commands.triggervolume.templategroup.desc"); + } + + @Override + protected void execute(@Nonnull CommandContext context, @Nonnull World world, @Nonnull Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + if (tvPlugin == null) { + return; + } + TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType()); + if (manager == null) { + return; + } + + String pack = packArg.get(context); + String groupName = groupNameArg.get(context); + String templateId = templateIdArg.get(context); + String name = nameArg.get(context); + + MinigameVolumeTemplateExport.ExportResult result = + MinigameVolumeTemplateExport.exportGroup(manager, pack, groupName, templateId, name); + if (!result.success()) { + String reason = result.reason(); + if ("invalid_id".equals(reason)) { + context.sendMessage(Message.translation("server.commands.triggervolume.templategroup.invalidId") + .param("id", templateId)); + } else if ("group_not_found".equals(reason)) { + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.notFound") + .param("name", groupName)); + } else if ("empty_group".equals(reason)) { + context.sendMessage(Message.translation("server.commands.triggervolume.clonegroup.empty") + .param("name", groupName)); + } else { + context.sendMessage(Message.translation("server.commands.triggervolume.templategroup.failed") + .param("id", templateId) + .param("error", reason)); + } + return; + } + + context.sendMessage(Message.translation("server.commands.triggervolume.templategroup.success") + .param("group", groupName) + .param("id", templateId) + .param("pack", result.packName()) + .param("count", String.valueOf(result.volumeCount()))); + } +} diff --git a/src/main/java/net/kewwbec/minigames/model/MinigameVolumeTemplate.java b/src/main/java/net/kewwbec/minigames/model/MinigameVolumeTemplate.java new file mode 100644 index 0000000..edefa65 --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/model/MinigameVolumeTemplate.java @@ -0,0 +1,200 @@ +package net.kewwbec.minigames.model; + +import com.hypixel.hytale.assetstore.AssetExtraInfo; +import com.hypixel.hytale.assetstore.AssetKeyValidator; +import com.hypixel.hytale.assetstore.AssetRegistry; +import com.hypixel.hytale.assetstore.AssetStore; +import com.hypixel.hytale.assetstore.codec.AssetBuilderCodec; +import com.hypixel.hytale.assetstore.map.DefaultAssetMap; +import com.hypixel.hytale.assetstore.map.JsonAssetWithMap; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.codec.Codec; +import com.hypixel.hytale.codec.KeyedCodec; +import com.hypixel.hytale.codec.codecs.array.ArrayCodec; +import com.hypixel.hytale.codec.codecs.map.MapCodec; +import com.hypixel.hytale.codec.validation.ValidatorCache; +import com.hypixel.hytale.codec.validation.Validators; + +import javax.annotation.Nonnull; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class MinigameVolumeTemplate implements JsonAssetWithMap> { + public static final AssetBuilderCodec CODEC = AssetBuilderCodec.builder( + MinigameVolumeTemplate.class, + MinigameVolumeTemplate::new, + Codec.STRING, + (template, id) -> template.id = id, + template -> template.id, + (asset, data) -> asset.data = data, + asset -> asset.data + ) + .appendInherited( + new KeyedCodec<>("Name", Codec.STRING), + (template, value) -> template.name = value, + template -> template.name, + (template, parent) -> template.name = parent.name + ) + .addValidator(Validators.nonNull()) + .add() + .appendInherited( + new KeyedCodec<>("Description", Codec.STRING, false), + (template, value) -> template.description = value, + template -> template.description, + (template, parent) -> template.description = parent.description + ) + .add() + .appendInherited( + new KeyedCodec<>("GroupId", Codec.STRING, false), + (template, value) -> template.groupId = value, + template -> template.groupId, + (template, parent) -> template.groupId = parent.groupId + ) + .add() + .appendInherited( + new KeyedCodec<>("GroupColor", Codec.INTEGER, false), + (template, value) -> template.groupColor = value, + template -> template.groupColor, + (template, parent) -> template.groupColor = parent.groupColor + ) + .add() + .>appendInherited( + new KeyedCodec<>("GroupTags", new MapCodec<>(Codec.STRING, HashMap::new, false), false), + (template, value) -> template.groupTags = value, + template -> template.groupTags, + (template, parent) -> template.groupTags = parent.groupTags + ) + .add() + .appendInherited( + new KeyedCodec<>("Volumes", new ArrayCodec<>(TemplateVolume.CODEC, TemplateVolume[]::new)), + (template, value) -> template.volumes = value, + template -> template.volumes, + (template, parent) -> template.volumes = parent.volumes + ) + .addValidator(Validators.nonNull()) + .add() + .build(); + + public static final ValidatorCache VALIDATOR_CACHE = + new ValidatorCache<>(new AssetKeyValidator<>(MinigameVolumeTemplate::getAssetStore)); + + private static AssetStore> ASSET_STORE; + + protected AssetExtraInfo.Data data; + protected String id; + protected String name; + protected String description = ""; + protected String groupId = ""; + protected int groupColor = 0; + protected Map groupTags = Collections.emptyMap(); + protected TemplateVolume[] volumes = new TemplateVolume[0]; + + protected MinigameVolumeTemplate() { + } + + public MinigameVolumeTemplate(@Nonnull String id) { + this.id = id; + } + + public MinigameVolumeTemplate( + @Nonnull String id, + @Nonnull String name, + @Nonnull String description, + @Nonnull String groupId, + int groupColor, + @Nonnull Map groupTags, + @Nonnull TemplateVolume[] volumes + ) { + this.id = id; + this.name = name; + this.description = description; + this.groupId = groupId; + this.groupColor = groupColor; + this.groupTags = groupTags; + this.volumes = volumes; + } + + public static AssetStore> getAssetStore() { + if (ASSET_STORE == null) { + ASSET_STORE = AssetRegistry.getAssetStore(MinigameVolumeTemplate.class); + } + return ASSET_STORE; + } + + public static DefaultAssetMap getAssetMap() { + return getAssetStore().getAssetMap(); + } + + public AssetExtraInfo.Data getData() { + return data; + } + + @Override + public String getId() { + return id; + } + + @Nonnull + public String getName() { + return name != null ? name : id; + } + + @Nonnull + public String getDescription() { + return description != null ? description : ""; + } + + @Nonnull + public String getGroupId() { + return groupId != null ? groupId : ""; + } + + public int getGroupColor() { + return groupColor; + } + + @Nonnull + public Map getGroupTags() { + return groupTags != null ? groupTags : Collections.emptyMap(); + } + + @Nonnull + public TemplateVolume[] getVolumes() { + return volumes != null ? volumes : new TemplateVolume[0]; + } + + public static final class TemplateVolume { + @Nonnull + public static final com.hypixel.hytale.codec.builder.BuilderCodec CODEC = + com.hypixel.hytale.codec.builder.BuilderCodec.builder(TemplateVolume.class, TemplateVolume::new) + .append(new KeyedCodec<>("Id", Codec.STRING), (volume, value) -> volume.id = value, volume -> volume.id) + .addValidator(Validators.nonNull()) + .add() + .append(new KeyedCodec<>("Volume", VolumeEntry.CODEC), (volume, value) -> volume.volume = value, volume -> volume.volume) + .addValidator(Validators.nonNull()) + .add() + .build(); + + private String id; + private VolumeEntry volume; + + public TemplateVolume() { + } + + public TemplateVolume(@Nonnull String id, @Nonnull VolumeEntry volume) { + this.id = id; + this.volume = volume; + } + + @Nonnull + public String getId() { + return id != null ? id : ""; + } + + @Nonnull + public VolumeEntry getVolume() { + return volume; + } + } +} diff --git a/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java index 26c67e3..5203bc9 100644 --- a/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java +++ b/src/main/java/net/kewwbec/minigames/service/MinigameMapDiscoveryService.java @@ -38,6 +38,7 @@ public final class MinigameMapDiscoveryService { public static final String TAG_VARIANT_VOTABLE = "VariantVotable"; public static final String ROLE_MAIN_ARENA = "MainArena"; public static final String ROLE_TEAM_SPAWN = "TeamSpawn"; + public static final String ROLE_PLAYER_SPAWN = "PlayerSpawn"; @Nonnull public MinigameMapDiscoveryResult discover(@Nullable TriggerContext context, @Nonnull String minigameId) { @@ -120,6 +121,36 @@ public final class MinigameMapDiscoveryService { @Nonnull PlayerMinigameState player, @Nullable String destinationOverride ) { + TriggerVolumeManager manager = manager(store); + return manager != null ? respawnDestination(manager, runtime, player, destinationOverride) + : explicitOrCheckpoint(player, destinationOverride); + } + + @Nonnull + public String respawnDestination( + @Nonnull TriggerVolumeManager manager, + @Nonnull MinigameRuntime runtime, + @Nonnull PlayerMinigameState player, + @Nullable String destinationOverride + ) { + String destination = explicitOrCheckpoint(player, destinationOverride); + if (!destination.isBlank()) { + return destination; + } + + String teamId = clean(player.teamId()); + if (!teamId.isBlank()) { + destination = teamSpawnDestination(manager, runtime, teamId); + if (!destination.isBlank()) { + return destination; + } + } + + return playerSpawnDestination(manager, runtime); + } + + @Nonnull + private static String explicitOrCheckpoint(@Nonnull PlayerMinigameState player, @Nullable String destinationOverride) { String destination = clean(destinationOverride); if (!destination.isBlank()) { return destination; @@ -129,12 +160,6 @@ public final class MinigameMapDiscoveryService { if (!destination.isBlank()) { return destination; } - - String teamId = clean(player.teamId()); - if (!teamId.isBlank()) { - return teamSpawnDestination(store, runtime, teamId); - } - return ""; } @@ -144,14 +169,43 @@ public final class MinigameMapDiscoveryService { if (manager == null || teamId.isBlank()) { return ""; } + return teamSpawnDestination(manager, runtime, teamId); + } + + @Nonnull + public String teamSpawnDestination(@Nonnull TriggerVolumeManager manager, @Nonnull MinigameRuntime runtime, @Nonnull String teamId) { + if (teamId.isBlank()) { + return ""; + } List candidates = manager.getVolumes().stream() .filter(volume -> isTeamSpawn(volume, runtime, teamId)) .toList(); + return randomDestination(candidates); + } + + @Nonnull + public String playerSpawnDestination(@Nullable Store store, @Nonnull MinigameRuntime runtime) { + TriggerVolumeManager manager = manager(store); + if (manager == null) { + return ""; + } + return playerSpawnDestination(manager, runtime); + } + + @Nonnull + public String playerSpawnDestination(@Nonnull TriggerVolumeManager manager, @Nonnull MinigameRuntime runtime) { + List candidates = manager.getVolumes().stream() + .filter(volume -> isPlayerSpawn(volume, runtime)) + .toList(); + return randomDestination(candidates); + } + + @Nonnull + private static String randomDestination(@Nonnull List candidates) { if (candidates.isEmpty()) { return ""; } - VolumeEntry selected = candidates.get(ThreadLocalRandom.current().nextInt(candidates.size())); var pos = selected.getPosition(); if (selected.getWorldName() != null && !selected.getWorldName().isBlank()) { @@ -167,9 +221,28 @@ public final class MinigameMapDiscoveryService { String variantId = clean(tags.get(TAG_VARIANT_ID)); String spawnRole = clean(tags.get(TAG_SPAWN_ROLE)); String spawnTeamId = clean(tags.get(TAG_TEAM_ID)); - if (!ROLE_TEAM_SPAWN.equals(spawnRole) || !teamId.equals(spawnTeamId)) { + if (!(ROLE_TEAM_SPAWN.equals(spawnRole) || ROLE_PLAYER_SPAWN.equals(spawnRole)) + || !teamId.equals(spawnTeamId)) { return false; } + return matchesRuntime(runtime, minigameId, mapId, variantId); + } + + private static boolean isPlayerSpawn(@Nonnull VolumeEntry volume, @Nonnull MinigameRuntime runtime) { + Map tags = volume.getRawTags(); + String minigameId = clean(tags.get(TAG_MINIGAME_ID)); + String mapId = clean(tags.get(TAG_MAP_ID)); + String variantId = clean(tags.get(TAG_VARIANT_ID)); + String spawnRole = clean(tags.get(TAG_SPAWN_ROLE)); + String spawnTeamId = clean(tags.get(TAG_TEAM_ID)); + if (!ROLE_PLAYER_SPAWN.equals(spawnRole) || !spawnTeamId.isBlank()) { + return false; + } + return matchesRuntime(runtime, minigameId, mapId, variantId); + } + + private static boolean matchesRuntime(@Nonnull MinigameRuntime runtime, @Nonnull String minigameId, + @Nonnull String mapId, @Nonnull String variantId) { if (!minigameId.isBlank() && !minigameId.equals(runtime.minigameId())) { return false; } diff --git a/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java b/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java index 46fe21a..65d1677 100644 --- a/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java +++ b/src/main/java/net/kewwbec/minigames/system/MinigameKillScoreSystem.java @@ -30,6 +30,7 @@ import java.util.Locale; import java.util.Map; import static net.kewwbec.minigames.model.Enums.FriendlyFireKillScoreMode.LOSES_POINTS; +import static net.kewwbec.minigames.model.Enums.MinigamePhase.ACTIVE; public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem { @Nonnull @@ -72,6 +73,9 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem { if (runtime == null) { return; } + if (runtime.phase() != ACTIVE) { + return; + } String victimKind = victimKind(store, victimRef); if (victimKind.isBlank()) { diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStore.java b/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStore.java new file mode 100644 index 0000000..1a93d7a --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStore.java @@ -0,0 +1,68 @@ +package net.kewwbec.minigames.ui; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; +import java.util.UUID; + +public final class MinigameTemplateBuilderDataStore { + private static final String KEY_MINIGAME_ID = "MinigameId"; + private static final String KEY_MAP_ID = "MapId"; + private static final String KEY_VARIANT_ID = "VariantId"; + + private final Path directory; + + public MinigameTemplateBuilderDataStore(@Nonnull Path pluginDataDirectory) { + this.directory = pluginDataDirectory.resolve("template-builder"); + } + + @Nonnull + public Defaults load(@Nonnull UUID playerId) { + Properties properties = new Properties(); + Path file = fileFor(playerId); + if (Files.isRegularFile(file)) { + try (InputStream in = Files.newInputStream(file)) { + properties.load(in); + } catch (IOException ignored) { + return Defaults.EMPTY; + } + } + return new Defaults( + clean(properties.getProperty(KEY_MINIGAME_ID)), + clean(properties.getProperty(KEY_MAP_ID)), + clean(properties.getProperty(KEY_VARIANT_ID)) + ); + } + + public void save(@Nonnull UUID playerId, @Nonnull Defaults defaults) { + try { + Files.createDirectories(directory); + Properties properties = new Properties(); + properties.setProperty(KEY_MINIGAME_ID, clean(defaults.minigameId())); + properties.setProperty(KEY_MAP_ID, clean(defaults.mapId())); + properties.setProperty(KEY_VARIANT_ID, clean(defaults.variantId())); + try (OutputStream out = Files.newOutputStream(fileFor(playerId))) { + properties.store(out, "Minigame template builder UI defaults"); + } + } catch (IOException ignored) { + } + } + + @Nonnull + private Path fileFor(@Nonnull UUID playerId) { + return directory.resolve(playerId + ".properties"); + } + + @Nonnull + private static String clean(String value) { + return value != null ? value.trim() : ""; + } + + public record Defaults(@Nonnull String minigameId, @Nonnull String mapId, @Nonnull String variantId) { + public static final Defaults EMPTY = new Defaults("", "", ""); + } +} diff --git a/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderUIService.java b/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderUIService.java new file mode 100644 index 0000000..86bb49f --- /dev/null +++ b/src/main/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderUIService.java @@ -0,0 +1,343 @@ +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.TextFieldBuilder; +import au.ellie.hyui.builders.UIElementBuilder; +import au.ellie.hyui.events.UIContext; +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.protocol.packets.interface_.CustomUIEventBindingType; +import com.hypixel.hytale.server.core.Message; +import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent; +import com.hypixel.hytale.server.core.universe.PlayerRef; +import com.hypixel.hytale.server.core.universe.world.World; +import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; +import com.hypixel.hytale.logger.HytaleLogger; +import net.kewwbec.minigames.adapter.HytaleAdapters; +import net.kewwbec.minigames.command.MinigameVolumeTemplateSpawn; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; + +public final class MinigameTemplateBuilderUIService { + private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass(); + private static final long TTL_MS = 10_000; + private static final int SECTION_INSET = 20; + private static final int WINDOW_WIDTH = 930; + private static final int WINDOW_HEIGHT = 520; + private static final int CONTENT_WIDTH = 894; + private static final int CONTENT_HEIGHT = 430; + private static final int FIELDS_WIDTH = 360; + private static final int LIST_WIDTH = 516; + private static final String MINIGAME_FIELD_ID = "templateBuilderMinigameId"; + private static final String MAP_FIELD_ID = "templateBuilderMapId"; + private static final String VARIANT_FIELD_ID = "templateBuilderVariantId"; + + private final MinigameTemplateBuilderDataStore dataStore; + private final ConcurrentHashMap pendingOpens = new ConcurrentHashMap<>(); + @Nullable + private HytaleAdapters.PlayerAdapter playerAdapter; + + public MinigameTemplateBuilderUIService(@Nonnull MinigameTemplateBuilderDataStore dataStore) { + this.dataStore = dataStore; + } + + public void setPlayerAdapter(@Nullable HytaleAdapters.PlayerAdapter playerAdapter) { + this.playerAdapter = playerAdapter; + } + + public void requestOpen(@Nonnull PlayerRef player) { + pendingOpens.put(player.getUuid(), System.currentTimeMillis()); + } + + public void openQueued(@Nonnull World world, @Nonnull Store store) { + long now = System.currentTimeMillis(); + TriggerVolumeManager manager = manager(store); + if (manager == null) return; + + for (var entry : pendingOpens.entrySet()) { + UUID uuid = entry.getKey(); + long createdAt = entry.getValue(); + if (now - createdAt > TTL_MS) { + pendingOpens.remove(uuid, createdAt); + continue; + } + Ref ref = store.getExternalData().getRefFromUUID(uuid); + if (ref == null || !ref.isValid()) continue; + PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType()); + if (player == null) continue; + if (pendingOpens.remove(uuid, createdAt)) { + try { + open(player, ref, world, store, manager); + } catch (Throwable e) { + LOGGER.atWarning().log("Template builder UI open failed player=%s: %s: %s", + uuid, e.getClass().getSimpleName(), e.getMessage()); + } + } + } + } + + public void open(@Nonnull PlayerRef player, + @Nonnull Ref playerEntity, + @Nonnull World world, + @Nonnull Store store, + @Nonnull TriggerVolumeManager manager) { + MinigameTemplateBuilderDataStore.Defaults defaults = dataStore.load(player.getUuid()); + List templates = MinigameVolumeTemplate.getAssetMap().getAssetMap().values().stream() + .sorted(Comparator.comparing(MinigameVolumeTemplate::getId)) + .toList(); + + GroupBuilder fields = surface("Template Variables", "Saved to this builder until changed.", FIELDS_WIDTH, CONTENT_HEIGHT); + fields.addChild(fieldRow("MinigameId", MINIGAME_FIELD_ID, defaults.minigameId(), "Required", (value, ui) -> + dataStore.save(player.getUuid(), defaultsFromUi(ui, defaults)))); + fields.addChild(spacer(FIELDS_WIDTH, 8)); + fields.addChild(fieldRow("MapId", MAP_FIELD_ID, defaults.mapId(), "Required", (value, ui) -> + dataStore.save(player.getUuid(), defaultsFromUi(ui, defaults)))); + fields.addChild(spacer(FIELDS_WIDTH, 8)); + fields.addChild(fieldRow("VariantId", VARIANT_FIELD_ID, defaults.variantId(), "Optional", (value, ui) -> + dataStore.save(player.getUuid(), defaultsFromUi(ui, defaults)))); + fields.addChild(spacer(FIELDS_WIDTH, 12)); + fields.addChild(inset( + ButtonBuilder.secondaryTextButton() + .withText("Save Variables") + .withTooltipText("Save the current field values without spawning a template") + .withAnchor(new HyUIAnchor().setWidth(310).setHeight(38)) + .onClick((ignored, ui) -> { + MinigameTemplateBuilderDataStore.Defaults current = defaultsFromUi(ui, defaults); + dataStore.save(player.getUuid(), current); + send(player, Message.translation("server.commands.triggervolume.minigametemplates.saved")); + }), + FIELDS_WIDTH, 310, 38)); + + int contentHeight = Math.max(CONTENT_HEIGHT, 72 + templates.size() * 50); + GroupBuilder templateList = surface("Templates", "Pick a template to spawn at your position.", LIST_WIDTH, CONTENT_HEIGHT) + .withContentHeight(contentHeight) + .withKeepScrollPosition(true); + if (templates.isEmpty()) { + templateList.addChild(inset(caption("No minigame volume templates are loaded.", 456), LIST_WIDTH, 456, 24)); + } else { + for (MinigameVolumeTemplate template : templates) { + templateList.addChild(templateRow(player, playerEntity, world, store, manager, template, defaults)); + templateList.addChild(spacer(LIST_WIDTH, 6)); + } + } + + ContainerBuilder container = compactDecoratedContainer("Minigame Volume Templates", WINDOW_WIDTH, WINDOW_HEIGHT) + .withPadding(HyUIPadding.symmetric(18, 14)) + .withLayoutMode("Top") + .withBackground(patch("#101722")) + .addContentChild(GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(CONTENT_WIDTH).setHeight(CONTENT_HEIGHT)) + .addChild(fields) + .addChild(spacer(18, CONTENT_HEIGHT)) + .addChild(templateList)); + + PageBuilder.pageForPlayer(player).addElement(container).open(player, store); + } + + private GroupBuilder templateRow(PlayerRef player, + Ref playerEntity, + World world, + Store store, + TriggerVolumeManager manager, + MinigameVolumeTemplate template, + MinigameTemplateBuilderDataStore.Defaults defaults) { + String label = template.getId() + " | " + template.getVolumes().length + " volume(s)"; + String tooltip = template.getDescription().isBlank() ? template.getName() : template.getDescription(); + return GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(LIST_WIDTH).setHeight(44)) + .addChild(spacer(SECTION_INSET, 44)) + .addChild(ButtonBuilder.secondaryTextButton() + .withText(label) + .withTooltipText(tooltip) + .withAnchor(new HyUIAnchor().setWidth(456).setHeight(44)) + .onClick((ignored, ui) -> spawnFromUi(player, playerEntity, world, store, manager, template, defaults, ui))); + } + + private void spawnFromUi(PlayerRef player, + Ref playerEntity, + World world, + Store store, + TriggerVolumeManager manager, + MinigameVolumeTemplate template, + MinigameTemplateBuilderDataStore.Defaults fallback, + UIContext ui) { + MinigameTemplateBuilderDataStore.Defaults uiDefaults = defaultsFromUi(ui, fallback); + dataStore.save(player.getUuid(), uiDefaults); + + MinigameVolumeTemplateSpawn.TemplateArgs args = + new MinigameVolumeTemplateSpawn.TemplateArgs(uiDefaults.minigameId(), uiDefaults.mapId(), uiDefaults.variantId()); + if (!args.isValid()) { + send(player, Message.translation("server.commands.triggervolume.minigametemplate.missingIds")); + return; + } + + TransformComponent transform = store.getComponent(playerEntity, TransformComponent.getComponentType()); + if (transform == null) { + send(player, Message.translation("server.commands.triggervolume.minigametemplate.playerRequired")); + return; + } + + MinigameVolumeTemplateSpawn.SpawnResult result = MinigameVolumeTemplateSpawn.spawn( + template, + args, + manager, + world.getName(), + transform.getPosition() + ); + if (!result.success()) { + if ("conflict".equals(result.reason())) { + send(player, Message.translation("server.commands.triggervolume.minigametemplate.conflict") + .param("names", String.join(", ", result.volumeIds()))); + } else if ("empty_template".equals(result.reason())) { + send(player, Message.translation("server.commands.triggervolume.minigametemplate.empty") + .param("name", template.getId())); + } else { + send(player, Message.translation("server.commands.triggervolume.minigametemplate.missingIds")); + } + return; + } + + if (!result.volumeIds().isEmpty()) { + manager.setPlayerSelection(player.getUuid(), result.volumeIds().get(0)); + } + send(player, Message.translation("server.commands.triggervolume.minigametemplate.success") + .param("template", template.getId()) + .param("count", String.valueOf(result.volumeIds().size())) + .param("prefix", MinigameVolumeTemplateSpawn.spawnPrefix(args))); + } + + private MinigameTemplateBuilderDataStore.Defaults defaultsFromUi( + UIContext ui, + MinigameTemplateBuilderDataStore.Defaults fallback + ) { + return new MinigameTemplateBuilderDataStore.Defaults( + clean(ui.getValue(MINIGAME_FIELD_ID, String.class).orElse(fallback.minigameId())), + clean(ui.getValue(MAP_FIELD_ID, String.class).orElse(fallback.mapId())), + clean(ui.getValue(VARIANT_FIELD_ID, String.class).orElse(fallback.variantId())) + ); + } + + @Nullable + private TriggerVolumeManager manager(Store store) { + TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get(); + return tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null; + } + + private void send(PlayerRef player, Message message) { + if (playerAdapter != null) { + playerAdapter.sendMessage(player.getUuid().toString(), message); + } + } + + private static GroupBuilder fieldRow(String label, + String fieldId, + String value, + String placeholder, + BiConsumer onChange) { + return GroupBuilder.group() + .withLayoutMode("Left") + .withAnchor(new HyUIAnchor().setWidth(FIELDS_WIDTH).setHeight(38)) + .addChild(spacer(SECTION_INSET, 38)) + .addChild(LabelBuilder.label() + .withText(label) + .withAnchor(new HyUIAnchor().setWidth(100).setHeight(38)) + .withStyle(textStyle(12, "#dbe7f6", true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8))) + .addChild(TextFieldBuilder.textInput() + .withId(fieldId) + .withValue(value) + .withPlaceholderText(placeholder) + .withMaxLength(64) + .withAnchor(new HyUIAnchor().setWidth(210).setHeight(38)) + .addEventListener(CustomUIEventBindingType.ValueChanged, (changed, ui) -> onChange.accept(clean(changed), ui))); + } + + 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 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 static String clean(String value) { + return value != null ? value.trim() : ""; + } +} diff --git a/src/main/resources/Server/Languages/en-US/server.lang b/src/main/resources/Server/Languages/en-US/server.lang index b4962a1..986acd0 100644 --- a/src/main/resources/Server/Languages/en-US/server.lang +++ b/src/main/resources/Server/Languages/en-US/server.lang @@ -323,3 +323,28 @@ server.commands.triggervolume.clonegroup.empty = Group '{name}' has no member vo server.commands.triggervolume.clonegroup.success = Cloned group '{source}' to '{name}' with {count} volumes server.commands.triggervolume.create.alreadyExists = A trigger volume named '{name}' already exists + +# /triggervolume minigametemplate +server.commands.triggervolume.minigametemplate.desc = Spawn a minigame trigger-volume template at your position +server.commands.triggervolume.minigametemplate.templateName.desc = Minigame volume template asset id +server.commands.triggervolume.minigametemplate.flags.desc = Required flags: --MinigameId= --MapId=; optional --VariantId= +server.commands.triggervolume.minigametemplate.playerRequired = This command must be run by a player in a world +server.commands.triggervolume.minigametemplate.notFound = No minigame volume template found with name '{name}' +server.commands.triggervolume.minigametemplate.missingIds = MinigameId and MapId are required, e.g. --MinigameId=Fishing --MapId=Cold +server.commands.triggervolume.minigametemplate.conflict = Template spawn cancelled because these volumes or groups already exist: {names} +server.commands.triggervolume.minigametemplate.empty = Template '{name}' has no volumes +server.commands.triggervolume.minigametemplate.success = Spawned template '{template}' as {count} trigger volumes using prefix '{prefix}' + +# /triggervolume minigametemplates +server.commands.triggervolume.minigametemplates.desc = Open the minigame trigger-volume template builder UI +server.commands.triggervolume.minigametemplates.saved = Saved template builder variables + +# /triggervolume templategroup +server.commands.triggervolume.templategroup.desc = Convert a trigger volume group into a minigame volume template asset +server.commands.triggervolume.templategroup.pack.desc = Writable asset pack to save the template into +server.commands.triggervolume.templategroup.groupName.desc = Trigger volume group to export +server.commands.triggervolume.templategroup.templateId.desc = New minigame volume template asset id +server.commands.triggervolume.templategroup.name.desc = Display name for the new template +server.commands.triggervolume.templategroup.invalidId = Invalid template id '{id}'. Use letters, numbers, underscores, or dashes. +server.commands.triggervolume.templategroup.failed = Failed to export template '{id}': {error} +server.commands.triggervolume.templategroup.success = Exported group '{group}' to template '{id}' in pack '{pack}' with {count} volumes diff --git a/src/main/resources/Server/MinigameVolumeTemplates/capture_point.json b/src/main/resources/Server/MinigameVolumeTemplates/capture_point.json new file mode 100644 index 0000000..8948baf --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/capture_point.json @@ -0,0 +1,44 @@ +{ + "Name": "Capture Point", + "Description": "A team control point starter volume that awards team score from the TeamId tag.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "capture_point" + }, + "Volumes": [ + { + "Id": "Template_CapturePoint", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -3.0, "Y": 0.0, "Z": -3.0 }, "Max": { "X": 3.0, "Y": 4.0, "Z": 3.0 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ + { + "Type": "ModifyScore", + "Event": "ENTER", + "TargetType": "TEAM", + "TargetSource": "TRIGGERING_TEAM", + "Operation": "ADD", + "Amount": 1 + } + ], + "Cooldown": 1.0, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "ObjectiveRole": "CapturePoint", + "ObjectiveId": "point_a", + "TemplatePurpose": "CapturePoint" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/checkpoint.json b/src/main/resources/Server/MinigameVolumeTemplates/checkpoint.json new file mode 100644 index 0000000..d25a636 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/checkpoint.json @@ -0,0 +1,33 @@ +{ + "Name": "Checkpoint", + "Description": "Active-game checkpoint marker for race, parkour, deathrun, and adventure maps.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "checkpoint" + }, + "Volumes": [ + { + "Id": "Template_Checkpoint", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -2.0, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 2.0, "Y": 3.0, "Z": 0.5 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "SetSpawnPoint", "Event": "ENTER", "X": 0.0, "Y": 0.0, "Z": 0.0 } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "CheckpointId": "checkpoint_1", + "TemplatePurpose": "Checkpoint" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/core_lifecycle.json b/src/main/resources/Server/MinigameVolumeTemplates/core_lifecycle.json new file mode 100644 index 0000000..4d184ac --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/core_lifecycle.json @@ -0,0 +1,109 @@ +{ + "Name": "Core Lifecycle", + "Description": "Starter lifecycle volumes for a map: arena, queue, start, activation, timer, and end.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "core_lifecycle" + }, + "Volumes": [ + { + "Id": "Template_MainArena", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -8.0, "Y": 0.0, "Z": -8.0 }, "Max": { "X": 8.0, "Y": 8.0, "Z": 8.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "MapRole": "MainArena", + "TemplatePurpose": "MainArena" + } + } + }, + { + "Id": "Template_JoinQueue", + "Volume": { + "Position": { "X": -6.0, "Y": 0.0, "Z": 10.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "JoinMinigameQueue", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "JoinQueue" + } + } + }, + { + "Id": "Template_LeaveQueue", + "Volume": { + "Position": { "X": -3.0, "Y": 0.0, "Z": 10.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "LeaveMinigameQueue", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "LeaveQueue" + } + } + }, + { + "Id": "Template_StartQueued", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 10.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "StartQueuedMinigame", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "StartQueued" + } + } + }, + { + "Id": "Template_ActivateGame", + "Volume": { + "Position": { "X": 3.0, "Y": 0.0, "Z": 10.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ + { "Type": "ResetScores", "Event": "TAG_ADDED" }, + { "Type": "SetMinigamePhase", "Event": "TAG_ADDED", "Phase": "ACTIVE" }, + { "Type": "StartTimer", "Event": "TAG_ADDED", "TimerName": "round", "DurationSeconds": 300 } + ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalPhaseStart": "STARTING", + "TemplatePurpose": "ActivateGame" + } + } + }, + { + "Id": "Template_EndOnTimer", + "Volume": { + "Position": { "X": 6.0, "Y": 0.0, "Z": 10.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "EndMinigame", "Event": "TAG_ADDED" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalOnTimerExpire": "round", + "TemplatePurpose": "EndOnTimer" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/deathzone.json b/src/main/resources/Server/MinigameVolumeTemplates/deathzone.json new file mode 100644 index 0000000..ccc9775 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/deathzone.json @@ -0,0 +1,37 @@ +{ + "Name": "Death Zone", + "Description": "Active-game death zone for lava, void, traps, and elimination hazards.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "deathzone" + }, + "Volumes": [ + { + "Id": "Template_DeathZone", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -4.0, "Y": 0.0, "Z": -4.0 }, "Max": { "X": 4.0, "Y": 2.0, "Z": 4.0 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ + { "Type": "ModifyLives", "Event": "ENTER", "TargetType": "PLAYER", "TargetSource": "TRIGGERING_PLAYER", "Operation": "SUBTRACT", "Amount": 1 }, + { "Type": "RespawnPlayer", "Event": "ENTER" } + ], + "Cooldown": 1.0, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "HazardRole": "DeathZone", + "TemplatePurpose": "DeathZone" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/item_spawn.json b/src/main/resources/Server/MinigameVolumeTemplates/item_spawn.json new file mode 100644 index 0000000..37d4ffe --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/item_spawn.json @@ -0,0 +1,39 @@ +{ + "Name": "Item Spawn", + "Description": "Standalone item respawn volume. Change ItemId after spawning.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "item_spawn" + }, + "Volumes": [ + { + "Id": "Template_ItemSpawn", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -0.5, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 0.5, "Y": 1.0, "Z": 0.5 } }, + "Conditions": [ { "Type": "MinigamePhase", "Event": "TICK", "Phase": "ACTIVE" } ], + "Effects": [ + { + "Type": "SpawnItem", + "Event": "TICK", + "ItemId": "Placeholder_Item", + "Amount": 1, + "RespawnDelaySeconds": 15, + "SpawnKey": "item_spawn" + } + ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "ItemSpawn", + "TemplatePurpose": "ItemSpawn" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/join_late_spectator.json b/src/main/resources/Server/MinigameVolumeTemplates/join_late_spectator.json new file mode 100644 index 0000000..08f1464 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/join_late_spectator.json @@ -0,0 +1,59 @@ +{ + "Name": "Join Late Or Spectator", + "Description": "Queue UI and entry pads for games that allow midgame join or spectators.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "join_late_spectator" + }, + "Volumes": [ + { + "Id": "Template_OpenQueueUI", + "Volume": { + "Position": { "X": -2.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "OpenMinigameQueueUI", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "UIQueue": "Local", + "TemplatePurpose": "OpenQueueUI" + } + } + }, + { + "Id": "Template_JoinOrSpectate", + "Volume": { + "Position": { "X": 1.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "JoinMinigameQueue", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "JoinOrSpectate" + } + } + }, + { + "Id": "Template_SpectatorSpawn", + "Volume": { + "Position": { "X": 0.0, "Y": 4.0, "Z": 6.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "SpectatorSpawn", + "TemplatePurpose": "SpectatorSpawn" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/map_vote_pad.json b/src/main/resources/Server/MinigameVolumeTemplates/map_vote_pad.json new file mode 100644 index 0000000..fc40030 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/map_vote_pad.json @@ -0,0 +1,28 @@ +{ + "Name": "Map Vote Pad", + "Description": "Vote pad for waiting sessions. MapId defaults from the spawned volume tag.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "map_vote_pad" + }, + "Volumes": [ + { + "Id": "Template_MapVote", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "VoteForMap", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "MapVote" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/npc_spawn.json b/src/main/resources/Server/MinigameVolumeTemplates/npc_spawn.json new file mode 100644 index 0000000..5a66189 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/npc_spawn.json @@ -0,0 +1,40 @@ +{ + "Name": "NPC Spawn", + "Description": "Standalone NPC respawn volume. Change RoleId after spawning.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "npc_spawn" + }, + "Volumes": [ + { + "Id": "Template_NpcSpawn", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Conditions": [ { "Type": "MinigamePhase", "Event": "TICK", "Phase": "ACTIVE" } ], + "Effects": [ + { + "Type": "SpawnNpc", + "Event": "TICK", + "RoleId": "fox", + "Count": 1, + "RespawnDelaySeconds": 20, + "SpawnKey": "npc_spawn", + "SpreadRadius": 0.0 + } + ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "NpcSpawn", + "TemplatePurpose": "NpcSpawn" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/objective_progress.json b/src/main/resources/Server/MinigameVolumeTemplates/objective_progress.json new file mode 100644 index 0000000..d66383b --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/objective_progress.json @@ -0,0 +1,34 @@ +{ + "Name": "Objective Progress", + "Description": "Active-game objective progress volume for capture, collection, defense, and adventure goals.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "objective_progress" + }, + "Volumes": [ + { + "Id": "Template_ObjectiveProgress", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -2.0, "Y": 0.0, "Z": -2.0 }, "Max": { "X": 2.0, "Y": 3.0, "Z": 2.0 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "ModifyObjectiveProgress", "Event": "ENTER", "ObjectiveId": "objective_a", "Operation": "ADD", "Amount": 1 } ], + "Cooldown": 1.0, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "ObjectiveId": "objective_a", + "TemplatePurpose": "ObjectiveProgress" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/player_activation_spawn.json b/src/main/resources/Server/MinigameVolumeTemplates/player_activation_spawn.json new file mode 100644 index 0000000..ff44a14 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/player_activation_spawn.json @@ -0,0 +1,36 @@ +{ + "Name": "Player Activation Spawn", + "Description": "Activation pad that makes a queued player active and stores their checkpoint/spawn.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "player_activation_spawn" + }, + "Volumes": [ + { + "Id": "Template_ActivationSpawn", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ + { "Type": "SetPlayerStatus", "Event": "ENTER", "Status": "ACTIVE" }, + { "Type": "SetSpawnPoint", "Event": "ENTER", "X": 0.0, "Y": 0.0, "Z": 0.0 } + ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "PlayerSpawn", + "TemplatePurpose": "ActivationSpawn" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/race.json b/src/main/resources/Server/MinigameVolumeTemplates/race.json new file mode 100644 index 0000000..21ba923 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/race.json @@ -0,0 +1,101 @@ +{ + "Name": "Race", + "Description": "Race starter volumes with arena, start line, checkpoint, finish line, and activation volume.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "race" + }, + "Volumes": [ + { + "Id": "Template_MainArena", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -4.0, "Y": 0.0, "Z": -16.0 }, "Max": { "X": 4.0, "Y": 6.0, "Z": 16.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "MapRole": "MainArena", + "TemplatePurpose": "MainArena" + } + } + }, + { + "Id": "Template_StartLine", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": -12.0 }, + "Shape": { "Type": "Box", "Min": { "X": -4.0, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 4.0, "Y": 3.0, "Z": 0.5 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "StartPlayerTimer", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "StartLine" + } + } + }, + { + "Id": "Template_Checkpoint", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -4.0, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 4.0, "Y": 3.0, "Z": 0.5 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "SetSpawnPoint", "Event": "ENTER", "X": 0.0, "Y": 0.0, "Z": 0.0 } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "Checkpoint" + } + } + }, + { + "Id": "Template_FinishLine", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 12.0 }, + "Shape": { "Type": "Box", "Min": { "X": -4.0, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 4.0, "Y": 3.0, "Z": 0.5 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "StopPlayerTimer", "Event": "ENTER", "Operation": "BEST" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "FinishLine" + } + } + }, + { + "Id": "Template_ActivateGame", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 18.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "SetMinigamePhase", "Event": "TAG_ADDED", "Phase": "ACTIVE" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalPhaseStart": "STARTING", + "TemplatePurpose": "ActivateGame" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/score_zone.json b/src/main/resources/Server/MinigameVolumeTemplates/score_zone.json new file mode 100644 index 0000000..42d1d62 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/score_zone.json @@ -0,0 +1,34 @@ +{ + "Name": "Score Zone", + "Description": "Active-game scoring zone for king of the hill, delivery, goals, rings, and collection zones.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "score_zone" + }, + "Volumes": [ + { + "Id": "Template_ScoreZone", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -2.0, "Y": 0.0, "Z": -2.0 }, "Max": { "X": 2.0, "Y": 3.0, "Z": 2.0 } }, + "Conditions": [ + { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" }, + { "Type": "PlayerInMinigame", "Event": "ENTER" } + ], + "Effects": [ { "Type": "ModifyScore", "Event": "ENTER", "TargetType": "PLAYER", "TargetSource": "TRIGGERING_PLAYER", "Operation": "ADD", "Amount": 1 } ], + "Cooldown": 1.0, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "ScoreRole": "ScoreZone", + "TemplatePurpose": "ScoreZone" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/solo_arena.json b/src/main/resources/Server/MinigameVolumeTemplates/solo_arena.json new file mode 100644 index 0000000..6944d4e --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/solo_arena.json @@ -0,0 +1,61 @@ +{ + "Name": "Solo Arena", + "Description": "Solo arena starter volumes with arena boundary, spawn marker, and kill scoring.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "solo_arena" + }, + "Volumes": [ + { + "Id": "Template_MainArena", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -10.0, "Y": 0.0, "Z": -10.0 }, "Max": { "X": 10.0, "Y": 8.0, "Z": 10.0 } }, + "Conditions": [ { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" } ], + "Effects": [ { "Type": "AwardKillScore", "Event": "ENTER", "TargetIds": [ "Player" ], "Points": 1, "AwardTarget": "PLAYER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "MapRole": "MainArena", + "TemplatePurpose": "MainArena" + } + } + }, + { + "Id": "Template_PlayerSpawn", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": -8.0 }, + "Shape": { "Type": "Box", "Min": { "X": -0.5, "Y": 0.0, "Z": -0.5 }, "Max": { "X": 0.5, "Y": 1.0, "Z": 0.5 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "PlayerSpawn", + "TemplatePurpose": "PlayerSpawn" + } + } + }, + { + "Id": "Template_ActivateGame", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 12.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "SetMinigamePhase", "Event": "TAG_ADDED", "Phase": "ACTIVE" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalPhaseStart": "STARTING", + "TemplatePurpose": "ActivateGame" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/team_arena.json b/src/main/resources/Server/MinigameVolumeTemplates/team_arena.json new file mode 100644 index 0000000..5b75a9b --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/team_arena.json @@ -0,0 +1,94 @@ +{ + "Name": "Team Arena", + "Description": "Two-team arena starter volumes with team spawns and team kill scoring.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "team_arena" + }, + "Volumes": [ + { + "Id": "Template_MainArena", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -12.0, "Y": 0.0, "Z": -12.0 }, "Max": { "X": 12.0, "Y": 8.0, "Z": 12.0 } }, + "Conditions": [ { "Type": "MinigamePhase", "Event": "ENTER", "Phase": "ACTIVE" } ], + "Effects": [ { "Type": "AwardKillScore", "Event": "ENTER", "TargetIds": [ "Player" ], "Points": 1, "AwardTarget": "TEAM" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "MapRole": "MainArena", + "TemplatePurpose": "MainArena" + } + } + }, + { + "Id": "Template_RedSpawn", + "Volume": { + "Position": { "X": -8.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "TeamSpawn", + "TeamId": "red", + "TemplatePurpose": "RedSpawn" + } + } + }, + { + "Id": "Template_BlueSpawn", + "Volume": { + "Position": { "X": 8.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "TeamSpawn", + "TeamId": "blue", + "TemplatePurpose": "BlueSpawn" + } + } + }, + { + "Id": "Template_AssignTeams", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 14.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "AssignTeam", "Event": "TAG_ADDED", "BalanceTeams": true } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalPhaseStart": "STARTING", + "TemplatePurpose": "AssignTeams" + } + } + }, + { + "Id": "Template_ActivateGame", + "Volume": { + "Position": { "X": 3.0, "Y": 0.0, "Z": 14.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "SetMinigamePhase", "Event": "TAG_ADDED", "Phase": "ACTIVE" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SignalPhaseStart": "STARTING", + "TemplatePurpose": "ActivateGame" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/team_assignment_pad.json b/src/main/resources/Server/MinigameVolumeTemplates/team_assignment_pad.json new file mode 100644 index 0000000..230b971 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/team_assignment_pad.json @@ -0,0 +1,29 @@ +{ + "Name": "Team Assignment Pad", + "Description": "Pad that assigns the entering player to the TeamId tag.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "team_assignment_pad" + }, + "Volumes": [ + { + "Id": "Template_TeamAssign", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "AssignTeam", "Event": "ENTER", "TeamId": "red" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TeamId": "red", + "TemplatePurpose": "TeamAssign" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/timed_heal_pack.json b/src/main/resources/Server/MinigameVolumeTemplates/timed_heal_pack.json new file mode 100644 index 0000000..9b5cc02 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/timed_heal_pack.json @@ -0,0 +1,39 @@ +{ + "Name": "Timed Heal Pack", + "Description": "Overwatch-style timed heal pickup spot. Change ItemId to your healing item.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "timed_heal_pack" + }, + "Volumes": [ + { + "Id": "Template_HealPack", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -0.75, "Y": 0.0, "Z": -0.75 }, "Max": { "X": 0.75, "Y": 1.0, "Z": 0.75 } }, + "Conditions": [ { "Type": "MinigamePhase", "Event": "TICK", "Phase": "ACTIVE" } ], + "Effects": [ + { + "Type": "SpawnItem", + "Event": "TICK", + "ItemId": "Placeholder_HealPack", + "Amount": 1, + "RespawnDelaySeconds": 15, + "SpawnKey": "heal_pack" + } + ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "SpawnRole": "HealPack", + "TemplatePurpose": "HealPack" + } + } + } + ] +} diff --git a/src/main/resources/Server/MinigameVolumeTemplates/waiting_area.json b/src/main/resources/Server/MinigameVolumeTemplates/waiting_area.json new file mode 100644 index 0000000..1ba2434 --- /dev/null +++ b/src/main/resources/Server/MinigameVolumeTemplates/waiting_area.json @@ -0,0 +1,89 @@ +{ + "Name": "Waiting Area", + "Description": "Lobby waiting area with queue UI, join queue, leave queue, and start queued game pads.", + "GroupId": "Template", + "GroupTags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "Template": "waiting_area" + }, + "Volumes": [ + { + "Id": "Template_WaitingArea", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 0.0 }, + "Shape": { "Type": "Box", "Min": { "X": -5.0, "Y": 0.0, "Z": -5.0 }, "Max": { "X": 5.0, "Y": 4.0, "Z": 5.0 } }, + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "LobbyRole": "WaitingArea", + "UIQueue": "Local", + "TemplatePurpose": "WaitingArea" + } + } + }, + { + "Id": "Template_OpenQueueUI", + "Volume": { + "Position": { "X": -3.0, "Y": 0.0, "Z": 6.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "OpenMinigameQueueUI", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "OpenQueueUI" + } + } + }, + { + "Id": "Template_JoinQueue", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 6.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "JoinMinigameQueue", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "JoinQueue" + } + } + }, + { + "Id": "Template_LeaveQueue", + "Volume": { + "Position": { "X": 3.0, "Y": 0.0, "Z": 6.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "LeaveMinigameQueue", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "LeaveQueue" + } + } + }, + { + "Id": "Template_StartQueued", + "Volume": { + "Position": { "X": 0.0, "Y": 0.0, "Z": 9.0 }, + "Shape": { "Type": "Box", "Min": { "X": -1.0, "Y": 0.0, "Z": -1.0 }, "Max": { "X": 1.0, "Y": 2.0, "Z": 1.0 } }, + "Effects": [ { "Type": "StartQueuedMinigame", "Event": "ENTER" } ], + "Enabled": true, + "Tags": { + "MinigameId": "Template", + "MapId": "Template", + "VariantId": "Template", + "TemplatePurpose": "StartQueued" + } + } + } + ] +} diff --git a/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExportTest.java b/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExportTest.java new file mode 100644 index 0000000..49cb5e5 --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateExportTest.java @@ -0,0 +1,38 @@ +package net.kewwbec.minigames.command; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class MinigameVolumeTemplateExportTest { + @Test + void templateVolumeIdRemovesGroupPrefix() { + assertEquals("Template_MainArena", + MinigameVolumeTemplateExport.templateVolumeId("Battle_Castle_MainArena", "Battle_Castle")); + } + + @Test + void templateVolumeIdKeepsExistingTemplatePrefix() { + assertEquals("Template_RedSpawn", + MinigameVolumeTemplateExport.templateVolumeId("Template_RedSpawn", "Battle_Castle")); + } + + @Test + void templateTagsRewritesRuntimeIdsAndPreservesOtherTags() { + Map tags = MinigameVolumeTemplateExport.templateTags(Map.of( + "MinigameId", "Battle", + "MapId", "Castle", + "VariantId", "Night", + "SpawnRole", "TeamSpawn", + "TeamId", "red" + )); + + assertEquals("Template", tags.get("MinigameId")); + assertEquals("Template", tags.get("MapId")); + assertEquals("Template", tags.get("VariantId")); + assertEquals("TeamSpawn", tags.get("SpawnRole")); + assertEquals("red", tags.get("TeamId")); + } +} diff --git a/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawnTest.java b/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawnTest.java new file mode 100644 index 0000000..471747c --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/command/MinigameVolumeTemplateSpawnTest.java @@ -0,0 +1,105 @@ +package net.kewwbec.minigames.command; + +import com.hypixel.hytale.builtin.triggervolumes.EntityTargetType; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.builtin.triggervolumes.shape.BoxShape; +import net.kewwbec.minigames.model.MinigameVolumeTemplate; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class MinigameVolumeTemplateSpawnTest { + @Test + void parsesSpacedFlags() { + MinigameVolumeTemplateSpawn.TemplateArgs args = MinigameVolumeTemplateSpawn.parseArgs( + "--MinigameId=Battle --MapId=Castle --VariantId=Night" + ); + + assertEquals("Battle", args.minigameId()); + assertEquals("Castle", args.mapId()); + assertEquals("Night", args.variantId()); + } + + @Test + void parsesCompactFlags() { + MinigameVolumeTemplateSpawn.TemplateArgs args = MinigameVolumeTemplateSpawn.parseArgs( + "--MinigameId=Battle--MapId=Castle--VariantId=Night" + ); + + assertEquals("Battle", args.minigameId()); + assertEquals("Castle", args.mapId()); + assertEquals("Night", args.variantId()); + } + + @Test + void buildsSpawnedIdsWithAndWithoutVariant() { + var noVariant = new MinigameVolumeTemplateSpawn.TemplateArgs("Battle", "Castle", ""); + var variant = new MinigameVolumeTemplateSpawn.TemplateArgs("Battle", "Castle", "Night"); + + assertEquals("Battle_Castle_MainArena", MinigameVolumeTemplateSpawn.spawnedVolumeId("Template_MainArena", noVariant)); + assertEquals("Battle_Castle_Night_MainArena", MinigameVolumeTemplateSpawn.spawnedVolumeId("Template_MainArena", variant)); + } + + @Test + void rewritesTemplateTagsAndRemovesMissingVariant() { + var args = new MinigameVolumeTemplateSpawn.TemplateArgs("Battle", "Castle", ""); + + Map tags = MinigameVolumeTemplateSpawn.rewriteTags(Map.of( + "MinigameId", "Template", + "MapId", "Template", + "VariantId", "Template", + "TeamId", "red" + ), args); + + assertEquals("Battle", tags.get("MinigameId")); + assertEquals("Castle", tags.get("MapId")); + assertEquals("red", tags.get("TeamId")); + assertTrue(!tags.containsKey("VariantId")); + } + + @Test + void reportsConflictingPlannedIds() throws Exception { + MinigameVolumeTemplate template = templateWithVolumes("Template_MainArena", "Template_RedSpawn"); + var args = new MinigameVolumeTemplateSpawn.TemplateArgs("Battle", "Castle", ""); + + List conflicts = MinigameVolumeTemplateSpawn.conflictingVolumeIds( + template, + args, + id -> id.equals("Battle_Castle_RedSpawn") + ); + + assertEquals(List.of("Battle_Castle_RedSpawn"), conflicts); + } + + private static MinigameVolumeTemplate templateWithVolumes(String... ids) throws Exception { + MinigameVolumeTemplate template = new MinigameVolumeTemplate("test"); + MinigameVolumeTemplate.TemplateVolume[] volumes = new MinigameVolumeTemplate.TemplateVolume[ids.length]; + for (int i = 0; i < ids.length; i++) { + volumes[i] = new MinigameVolumeTemplate.TemplateVolume(ids[i], volume()); + } + + Field volumesField = MinigameVolumeTemplate.class.getDeclaredField("volumes"); + volumesField.setAccessible(true); + volumesField.set(template, volumes); + return template; + } + + private static VolumeEntry volume() { + return new VolumeEntry( + "", + "", + new Vector3d(), + new BoxShape(new Vector3d(-1.0, 0.0, -1.0), new Vector3d(1.0, 1.0, 1.0)), + List.of(), + EnumSet.of(EntityTargetType.PLAYER), + true + ); + } +} diff --git a/src/test/java/net/kewwbec/minigames/service/MinigameMapDiscoveryServiceTest.java b/src/test/java/net/kewwbec/minigames/service/MinigameMapDiscoveryServiceTest.java new file mode 100644 index 0000000..07deaf7 --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/service/MinigameMapDiscoveryServiceTest.java @@ -0,0 +1,99 @@ +package net.kewwbec.minigames.service; + +import com.hypixel.hytale.builtin.triggervolumes.EntityTargetType; +import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager; +import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry; +import com.hypixel.hytale.builtin.triggervolumes.shape.BoxShape; +import net.kewwbec.minigames.model.MinigameDefinition; +import net.kewwbec.minigames.model.PlayerMinigameState; +import net.kewwbec.minigames.runtime.MinigameRuntime; +import org.joml.Vector3d; +import org.junit.jupiter.api.Test; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class MinigameMapDiscoveryServiceTest { + private final MinigameMapDiscoveryService service = new MinigameMapDiscoveryService(); + + @Test + void respawnDestinationUsesRandomPlayerSpawnWhenPlayerHasNoTeam() { + TriggerVolumeManager manager = new TriggerVolumeManager(); + manager.register("spawn", spawn("spawn", 10, Map.of( + "MinigameId", "Battle", + "MapId", "Arena", + "SpawnRole", "PlayerSpawn" + ))); + + String destination = service.respawnDestination(manager, runtime(), player("p1", ""), null); + + assertEquals("world,10.0,64.0,20.0", destination); + } + + @Test + void respawnDestinationPrefersMatchingTeamSpawnBeforeGlobalPlayerSpawn() { + TriggerVolumeManager manager = new TriggerVolumeManager(); + manager.register("global", spawn("global", 10, Map.of( + "MinigameId", "Battle", + "MapId", "Arena", + "SpawnRole", "PlayerSpawn" + ))); + manager.register("red", spawn("red", 30, Map.of( + "MinigameId", "Battle", + "MapId", "Arena", + "SpawnRole", "TeamSpawn", + "TeamId", "red" + ))); + + String destination = service.respawnDestination(manager, runtime(), player("p1", "red"), null); + + assertEquals("world,30.0,64.0,20.0", destination); + } + + @Test + void respawnDestinationFallsBackToGlobalPlayerSpawnWhenTeamHasNoSpawn() { + TriggerVolumeManager manager = new TriggerVolumeManager(); + manager.register("global", spawn("global", 10, Map.of( + "MinigameId", "Battle", + "MapId", "Arena", + "SpawnRole", "PlayerSpawn" + ))); + manager.register("blue", spawn("blue", 30, Map.of( + "MinigameId", "Battle", + "MapId", "Arena", + "SpawnRole", "TeamSpawn", + "TeamId", "blue" + ))); + + String destination = service.respawnDestination(manager, runtime(), player("p1", "red"), null); + + assertEquals("world,10.0,64.0,20.0", destination); + } + + private static MinigameRuntime runtime() { + return new MinigameRuntime("runtime", new MinigameDefinition("Battle"), "Arena", ""); + } + + private static PlayerMinigameState player(String id, String teamId) { + PlayerMinigameState player = new PlayerMinigameState(id, "Battle"); + player.teamId(teamId); + return player; + } + + private static VolumeEntry spawn(String id, double x, Map tags) { + VolumeEntry entry = new VolumeEntry( + id, + "world", + new Vector3d(x, 64.0, 20.0), + new BoxShape(new Vector3d(-1.0, 0.0, -1.0), new Vector3d(1.0, 1.0, 1.0)), + List.of(), + EnumSet.of(EntityTargetType.PLAYER), + true + ); + entry.setTags(tags); + return entry; + } +} diff --git a/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java b/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java index 9c3acfb..e60e1a7 100644 --- a/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java +++ b/src/test/java/net/kewwbec/minigames/service/MinigameQueueServiceTest.java @@ -4,6 +4,7 @@ import net.kewwbec.minigames.model.MinigameDefinition; import net.kewwbec.minigames.model.MinigameMapCandidate; import net.kewwbec.minigames.runtime.MinigameRuntime; import net.kewwbec.minigames.runtime.MinigameRuntimeService; +import net.kewwbec.minigames.service.MinigameQueueService.JoinResult; import org.junit.jupiter.api.Test; import javax.annotation.Nullable; @@ -245,6 +246,15 @@ final class MinigameQueueServiceTest { public void onPlayerActivated(MinigameRuntime runtime, String playerId) { } + @Override + public void onPlayerSpectate(MinigameRuntime runtime, String playerId) { + } + + @Override + public JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId) { + return JoinResult.JOINED; + } + @Override public void removePlayer(MinigameRuntime runtime, String playerId, String reason) { } diff --git a/src/test/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStoreTest.java b/src/test/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStoreTest.java new file mode 100644 index 0000000..c4e8629 --- /dev/null +++ b/src/test/java/net/kewwbec/minigames/ui/MinigameTemplateBuilderDataStoreTest.java @@ -0,0 +1,34 @@ +package net.kewwbec.minigames.ui; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class MinigameTemplateBuilderDataStoreTest { + @TempDir + Path tempDir; + + @Test + void savesAndLoadsPlayerDefaults() { + MinigameTemplateBuilderDataStore store = new MinigameTemplateBuilderDataStore(tempDir); + UUID playerId = UUID.randomUUID(); + + store.save(playerId, new MinigameTemplateBuilderDataStore.Defaults("Battle", "Castle", "Night")); + + MinigameTemplateBuilderDataStore.Defaults loaded = store.load(playerId); + assertEquals("Battle", loaded.minigameId()); + assertEquals("Castle", loaded.mapId()); + assertEquals("Night", loaded.variantId()); + } + + @Test + void missingPlayerDataLoadsEmptyDefaults() { + MinigameTemplateBuilderDataStore store = new MinigameTemplateBuilderDataStore(tempDir); + + assertEquals(MinigameTemplateBuilderDataStore.Defaults.EMPTY, store.load(UUID.randomUUID())); + } +}