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.
This commit is contained in:
2026-06-21 13:04:16 -07:00
parent cefe008cae
commit ca11bbd4c0
38 changed files with 2655 additions and 23 deletions
+43
View File
@@ -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 <templateName> <flags>`
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 <pack> <groupName> <templateId> <name>`
Exports an existing trigger-volume group in the world as a new `MinigameVolumeTemplate`
asset in `<pack>/Server/MinigameVolumeTemplates/<templateId>.json`.
The group origin becomes the template origin. Member volumes are stored at positions
relative to that origin, their ids are rewritten to `Template_<Purpose>`, 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.
+4 -3
View File
@@ -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=<team id>`.
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=<team id>`. 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
+14 -6
View File
@@ -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.
+56 -4
View File
@@ -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 <pack> <groupName> <templateId> <name>` 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=<your minigame>`
- `MapId=<map id>`
- optional `VariantId=<variant id>`
- `SpawnRole=PlayerSpawn`
Tag each team-specific spawn volume with:
- `MinigameId=<your minigame>`
- `MapId=<map id>`
@@ -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
@@ -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<EntityStore, LockMountComponent> lockMountComponentType;
@@ -146,6 +153,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
.loadsAfter(TriggerEffectAsset.class)
.build()
);
AssetRegistry.register(
HytaleAssetStore.builder(MinigameVolumeTemplate.class, new DefaultAssetMap<String, MinigameVolumeTemplate>())
.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<EntityStore, LockMountComponent> 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.<String>of();
@@ -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<VolumeEntry> 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<VolumeEntry> 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<String, String> templateTags(@Nonnull Map<String, String> source) {
Map<String, String> 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<String, String> 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);
}
}
}
@@ -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<String, String> 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<String, String> rewriteTags(@Nonnull Map<String, String> source, @Nonnull TemplateArgs args) {
Map<String, String> 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<String> plannedVolumeIds(@Nonnull MinigameVolumeTemplate template, @Nonnull TemplateArgs args) {
List<String> ids = new ArrayList<>();
for (MinigameVolumeTemplate.TemplateVolume templateVolume : template.getVolumes()) {
ids.add(spawnedVolumeId(templateVolume.getId(), args));
}
return ids;
}
@Nonnull
public static List<String> conflictingVolumeIds(
@Nonnull MinigameVolumeTemplate template,
@Nonnull TemplateArgs args,
@Nonnull Predicate<String> exists
) {
List<String> 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<String> 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<String, String> groupTags = rewriteTags(template.getGroupTags(), args);
if (!groupTags.isEmpty()) {
group.setTags(groupTags);
}
manager.registerGroup(groupId, group);
}
List<String> 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<String, String> 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<String> volumeIds) {
@Nonnull
public static SpawnResult success(@Nonnull List<String> volumeIds) {
return new SpawnResult(true, "", List.copyOf(volumeIds));
}
@Nonnull
public static SpawnResult failure(@Nonnull String reason, @Nonnull List<String> conflicts) {
return new SpawnResult(false, reason, List.copyOf(conflicts));
}
}
}
@@ -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<String> templateArg =
this.withRequiredArg("templateName", "server.commands.triggervolume.minigametemplate.templateName.desc", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> store) {
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) {
return;
}
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) {
return;
}
Ref<EntityStore> 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)));
}
}
@@ -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<EntityStore> store) {
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
if (tvPlugin == null) {
return;
}
TriggerVolumeManager manager = store.getResource(tvPlugin.getManagerResourceType());
if (manager == null) {
return;
}
Ref<EntityStore> 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);
}
}
@@ -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<String> packArg =
this.withRequiredArg("pack", "server.commands.triggervolume.templategroup.pack.desc", ArgTypes.STRING);
private final RequiredArg<String> groupNameArg =
this.withRequiredArg("groupName", "server.commands.triggervolume.templategroup.groupName.desc", ArgTypes.STRING);
private final RequiredArg<String> templateIdArg =
this.withRequiredArg("templateId", "server.commands.triggervolume.templategroup.templateId.desc", ArgTypes.STRING);
private final RequiredArg<String> 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<EntityStore> 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())));
}
}
@@ -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<String, DefaultAssetMap<String, MinigameVolumeTemplate>> {
public static final AssetBuilderCodec<String, MinigameVolumeTemplate> 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
)
.<String>appendInherited(
new KeyedCodec<>("Name", Codec.STRING),
(template, value) -> template.name = value,
template -> template.name,
(template, parent) -> template.name = parent.name
)
.addValidator(Validators.nonNull())
.add()
.<String>appendInherited(
new KeyedCodec<>("Description", Codec.STRING, false),
(template, value) -> template.description = value,
template -> template.description,
(template, parent) -> template.description = parent.description
)
.add()
.<String>appendInherited(
new KeyedCodec<>("GroupId", Codec.STRING, false),
(template, value) -> template.groupId = value,
template -> template.groupId,
(template, parent) -> template.groupId = parent.groupId
)
.add()
.<Integer>appendInherited(
new KeyedCodec<>("GroupColor", Codec.INTEGER, false),
(template, value) -> template.groupColor = value,
template -> template.groupColor,
(template, parent) -> template.groupColor = parent.groupColor
)
.add()
.<Map<String, String>>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()
.<TemplateVolume[]>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<String> VALIDATOR_CACHE =
new ValidatorCache<>(new AssetKeyValidator<>(MinigameVolumeTemplate::getAssetStore));
private static AssetStore<String, MinigameVolumeTemplate, DefaultAssetMap<String, MinigameVolumeTemplate>> ASSET_STORE;
protected AssetExtraInfo.Data data;
protected String id;
protected String name;
protected String description = "";
protected String groupId = "";
protected int groupColor = 0;
protected Map<String, String> 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<String, String> 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<String, MinigameVolumeTemplate, DefaultAssetMap<String, MinigameVolumeTemplate>> getAssetStore() {
if (ASSET_STORE == null) {
ASSET_STORE = AssetRegistry.getAssetStore(MinigameVolumeTemplate.class);
}
return ASSET_STORE;
}
public static DefaultAssetMap<String, MinigameVolumeTemplate> 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<String, String> 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<TemplateVolume> 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;
}
}
}
@@ -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<VolumeEntry> candidates = manager.getVolumes().stream()
.filter(volume -> isTeamSpawn(volume, runtime, teamId))
.toList();
return randomDestination(candidates);
}
@Nonnull
public String playerSpawnDestination(@Nullable Store<EntityStore> 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<VolumeEntry> candidates = manager.getVolumes().stream()
.filter(volume -> isPlayerSpawn(volume, runtime))
.toList();
return randomDestination(candidates);
}
@Nonnull
private static String randomDestination(@Nonnull List<VolumeEntry> 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<String, String> 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;
}
@@ -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()) {
@@ -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("", "", "");
}
}
@@ -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<UUID, Long> 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<EntityStore> 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<EntityStore> 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<EntityStore> playerEntity,
@Nonnull World world,
@Nonnull Store<EntityStore> store,
@Nonnull TriggerVolumeManager manager) {
MinigameTemplateBuilderDataStore.Defaults defaults = dataStore.load(player.getUuid());
List<MinigameVolumeTemplate> 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<EntityStore> playerEntity,
World world,
Store<EntityStore> 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<EntityStore> playerEntity,
World world,
Store<EntityStore> 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<EntityStore> 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<String, UIContext> 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() : "";
}
}
@@ -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=<id> --MapId=<id>; optional --VariantId=<id>
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
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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"
}
}
}
]
}
@@ -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<String, String> 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"));
}
}
@@ -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<String, String> 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<String> 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
);
}
}
@@ -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<String, String> 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;
}
}
@@ -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) {
}
@@ -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()));
}
}