Compare commits
5 Commits
ff2e46a0d2
...
7394e8f874
| Author | SHA1 | Date | |
|---|---|---|---|
| 7394e8f874 | |||
| e992f18b8e | |||
| ca11bbd4c0 | |||
| cefe008cae | |||
| 511d0e8d4b |
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
A Hytale-native minigame extension for server-side minigame definitions, runtime state, and trigger-volume logic.
|
A Hytale-native minigame extension for server-side minigame definitions, runtime state, and trigger-volume logic.
|
||||||
|
|
||||||
The mod mirrors Hytale's own asset and trigger systems instead of maintaining a separate minigame-only registry layer. Minigames are `MinigameDefinition` assets, trigger logic is registered as real `TriggerEffect` and `TriggerCondition` codec entries, and runtime events are dispatched on Hytale's event bus.
|
The mod mirrors Hytale's own asset, trigger, and interaction systems instead of maintaining a separate minigame-only registry layer. Minigames are `MinigameDefinition` assets, trigger logic is registered as real `TriggerEffect` and `TriggerCondition` codec entries, item/entity/NPC actions can use real `Interaction` entries, and runtime events are dispatched on Hytale's event bus.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Asset-driven minigame definitions in `Server/Minigames/`
|
- Asset-driven minigame definitions in `Server/Minigames/`
|
||||||
- Hytale Asset Editor support through `MinigameDefinition.CODEC`
|
- Hytale Asset Editor support through `MinigameDefinition.CODEC`
|
||||||
- Hytale trigger-volume integration through native `TriggerEffect` and `TriggerCondition` types
|
- Hytale trigger-volume integration through native `TriggerEffect` and `TriggerCondition` types
|
||||||
|
- Hytale interaction integration through native `Interaction` types for item/entity/NPC use actions
|
||||||
- Asset-backed `MinigameId` picker fields in the trigger-volume editor
|
- Asset-backed `MinigameId` picker fields in the trigger-volume editor
|
||||||
|
- Asset-backed minigame trigger-volume templates in `Server/MinigameVolumeTemplates/`
|
||||||
- Runtime lifecycle commands for creating, enabling, starting, stopping, and resetting minigames
|
- Runtime lifecycle commands for creating, enabling, starting, stopping, and resetting minigames
|
||||||
- Multi-map runtime support using Hytale trigger-volume tags
|
- Multi-map runtime support using Hytale trigger-volume tags
|
||||||
- Player, team, score, lives, counter, status, and phase runtime state
|
- Player, team, score, lives, counter, status, and phase runtime state
|
||||||
@@ -67,6 +69,17 @@ Use normal Hytale trigger volumes for the area shape and interaction point, then
|
|||||||
|
|
||||||
For multi-map minigames, tag volumes with `MinigameId=<id>`, `MapId=<map>`, optional `VariantId=<variant>`, and `MapRole=MainArena` on the primary arena volume. Team spawn volumes use `SpawnRole=TeamSpawn` and `TeamId=<team>`. `MapSelectionMode` controls whether a queued match uses votes or random selection.
|
For multi-map minigames, tag volumes with `MinigameId=<id>`, `MapId=<map>`, optional `VariantId=<variant>`, and `MapRole=MainArena` on the primary arena volume. Team spawn volumes use `SpawnRole=TeamSpawn` and `TeamId=<team>`. `MapSelectionMode` controls whether a queued match uses votes or random selection.
|
||||||
|
|
||||||
|
To bootstrap common logic, spawn editable template volumes at your position:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/triggervolume minigametemplates
|
||||||
|
/triggervolume minigametemplate core_lifecycle --MinigameId=My_Minigame --MapId=Arena01
|
||||||
|
/triggervolume minigametemplate team_arena --MinigameId=Battle --MapId=Castle --VariantId=Night
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI remembers each builder's minigame/map variables. Both paths create normal Hytale
|
||||||
|
trigger volumes with template tags replaced by the provided IDs.
|
||||||
|
|
||||||
Available condition types:
|
Available condition types:
|
||||||
|
|
||||||
- `MinigamePhase`
|
- `MinigamePhase`
|
||||||
@@ -85,26 +98,30 @@ Available condition types:
|
|||||||
Available effect types:
|
Available effect types:
|
||||||
|
|
||||||
- `StartMinigame`
|
- `StartMinigame`
|
||||||
|
- `EndMinigame`
|
||||||
|
- `ResetMinigame`
|
||||||
|
- `SetMinigamePhase`
|
||||||
|
- `ResetScores`
|
||||||
|
- `ModifyScore`
|
||||||
|
- `ModifyCounter`
|
||||||
|
- `ModifyLives`
|
||||||
|
- `SetPlayerStatus`
|
||||||
|
- `SetPlayerLoadout`
|
||||||
- `JoinMinigameQueue`
|
- `JoinMinigameQueue`
|
||||||
- `LeaveMinigameQueue`
|
- `LeaveMinigameQueue`
|
||||||
- `VoteForMap`
|
- `VoteForMap`
|
||||||
- `StartQueuedMinigame`
|
- `StartQueuedMinigame`
|
||||||
- `SpawnItem`
|
- `SpawnItem`
|
||||||
- `AwardKillScore`
|
- `SpawnNpc`
|
||||||
- `EndMinigame`
|
- `MountPlayer`
|
||||||
- `ResetMinigame`
|
- `DismountPlayer`
|
||||||
- `SetMinigamePhase`
|
|
||||||
- `ResetScores`
|
|
||||||
- `ModifyPlayerScore`
|
|
||||||
- `ModifyTeamScore`
|
|
||||||
- `ModifyCounter`
|
|
||||||
- `ModifyPlayerLives`
|
|
||||||
- `SetPlayerStatus`
|
|
||||||
- `SetRound`
|
- `SetRound`
|
||||||
- `StartTimer`
|
- `StartTimer`
|
||||||
- `StopTimer`
|
- `StopTimer`
|
||||||
- `SetSpawnPoint`
|
- `SetSpawnPoint`
|
||||||
- `RespawnPlayer`
|
- `RespawnPlayer`
|
||||||
|
- `RespawnAllPlayers`
|
||||||
|
- `SwapTeams`
|
||||||
- `SendMinigameMessage`
|
- `SendMinigameMessage`
|
||||||
- `AssignTeam`
|
- `AssignTeam`
|
||||||
- `SaveInventory`
|
- `SaveInventory`
|
||||||
@@ -112,17 +129,122 @@ Available effect types:
|
|||||||
- `ClearInventory`
|
- `ClearInventory`
|
||||||
- `SetObjectiveStatus`
|
- `SetObjectiveStatus`
|
||||||
- `ModifyObjectiveProgress`
|
- `ModifyObjectiveProgress`
|
||||||
|
- `AwardKillScore`
|
||||||
|
- `PlaceBlocks`
|
||||||
|
- `OpenMinigameQueueUI`
|
||||||
|
- `StartPlayerTimer`
|
||||||
|
- `StopPlayerTimer`
|
||||||
|
|
||||||
Fields named `MinigameId` use an editor dropdown backed by registered `MinigameDefinition` assets.
|
Fields named `MinigameId` use an editor dropdown backed by registered `MinigameDefinition` assets.
|
||||||
|
|
||||||
|
## Interaction Logic
|
||||||
|
|
||||||
|
Many minigame effects also have Hytale interaction counterparts. Interaction type IDs use the `Minigame` prefix plus the trigger effect ID, for example `MinigameJoinMinigameQueue`, `MinigameModifyScore`, and `MinigameOpenMinigameQueueUI`.
|
||||||
|
|
||||||
|
Interaction-backed minigame actions use Hytale root interaction cooldown and chaining fields instead of trigger-volume `Event`, `Interval`, and `Delay`. Common routing fields are `MinigameId`, optional `MapId`, optional `VariantId`, and optional `TeamId`.
|
||||||
|
|
||||||
|
Available interaction types:
|
||||||
|
|
||||||
|
- `MinigameStartMinigame`
|
||||||
|
- `MinigameEndMinigame`
|
||||||
|
- `MinigameResetMinigame`
|
||||||
|
- `MinigameSetMinigamePhase`
|
||||||
|
- `MinigameResetScores`
|
||||||
|
- `MinigameModifyScore`
|
||||||
|
- `MinigameModifyCounter`
|
||||||
|
- `MinigameModifyLives`
|
||||||
|
- `MinigameSetPlayerStatus`
|
||||||
|
- `MinigameSetPlayerLoadout`
|
||||||
|
- `MinigameJoinMinigameQueue`
|
||||||
|
- `MinigameLeaveMinigameQueue`
|
||||||
|
- `MinigameVoteForMap`
|
||||||
|
- `MinigameStartQueuedMinigame`
|
||||||
|
- `MinigameSetRound`
|
||||||
|
- `MinigameStartTimer`
|
||||||
|
- `MinigameStopTimer`
|
||||||
|
- `MinigameSetSpawnPoint`
|
||||||
|
- `MinigameRespawnPlayer`
|
||||||
|
- `MinigameRespawnAllPlayers`
|
||||||
|
- `MinigameSwapTeams`
|
||||||
|
- `MinigameSendMinigameMessage`
|
||||||
|
- `MinigameAssignTeam`
|
||||||
|
- `MinigameSaveInventory`
|
||||||
|
- `MinigameRestoreInventory`
|
||||||
|
- `MinigameClearInventory`
|
||||||
|
- `MinigameSetObjectiveStatus`
|
||||||
|
- `MinigameModifyObjectiveProgress`
|
||||||
|
- `MinigameOpenMinigameQueueUI`
|
||||||
|
- `MinigameStartPlayerTimer`
|
||||||
|
- `MinigameStopPlayerTimer`
|
||||||
|
|
||||||
|
Volume-only for now: `AwardKillScore`, `SpawnItem`, `SpawnNpc`, `MountPlayer`, `DismountPlayer`, and `PlaceBlocks`.
|
||||||
|
|
||||||
|
Inline item secondary-use example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Interactions": {
|
||||||
|
"Secondary": {
|
||||||
|
"Interactions": [
|
||||||
|
{
|
||||||
|
"Type": "MinigameJoinMinigameQueue",
|
||||||
|
"MinigameId": "Skywars"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Cooldown": {
|
||||||
|
"Id": "join_skywars_queue",
|
||||||
|
"Cooldown": 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reusable assets:
|
||||||
|
|
||||||
|
`Server/Item/Interactions/join_skywars_queue.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Type": "MinigameJoinMinigameQueue",
|
||||||
|
"MinigameId": "Skywars"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Server/Item/RootInteractions/join_skywars_queue_secondary.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Interactions": [
|
||||||
|
"join_skywars_queue"
|
||||||
|
],
|
||||||
|
"Cooldown": {
|
||||||
|
"Id": "join_skywars_queue",
|
||||||
|
"Cooldown": 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference the root interaction from an item:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Interactions": {
|
||||||
|
"Secondary": "join_skywars_queue_secondary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
core-minigames/
|
core-minigames/
|
||||||
|-- src/main/java/net/kewwbec/minigames/
|
|-- src/main/java/net/kewwbec/minigames/
|
||||||
| |-- MinigameCorePlugin.java
|
| |-- MinigameCorePlugin.java
|
||||||
|
| |-- action/
|
||||||
| |-- command/
|
| |-- command/
|
||||||
| |-- event/
|
| |-- event/
|
||||||
|
| |-- interaction/
|
||||||
| |-- model/
|
| |-- model/
|
||||||
| |-- runtime/
|
| |-- runtime/
|
||||||
| |-- service/
|
| |-- service/
|
||||||
|
|||||||
@@ -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.
|
volume placement and attach the minigame trigger effects and conditions there.
|
||||||
`/triggervolume clone` and `/triggervolume clonegroup` (injected by this plugin) help
|
`/triggervolume clone` and `/triggervolume clonegroup` (injected by this plugin) help
|
||||||
duplicate configured volumes between maps.
|
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.
|
||||||
|
|||||||
@@ -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`.
|
1. The effect's explicit `DestinationId`.
|
||||||
2. The player's stored checkpoint from `SetSpawnPoint`.
|
2. The player's stored checkpoint from `SetSpawnPoint`.
|
||||||
3. A random team spawn volume matching the player's `TeamId`.
|
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.
|
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
|
## Item Spawners
|
||||||
|
|
||||||
|
|||||||
+14
-6
@@ -1,6 +1,6 @@
|
|||||||
# Trigger Volume Tags
|
# 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
|
## Map And Runtime Tags
|
||||||
|
|
||||||
@@ -34,17 +34,25 @@ Overlapping volumes are allowed. Volumes with the same `MinigameId`, `MapId`, an
|
|||||||
|
|
||||||
## Spawn Tags
|
## 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 |
|
| Tag | Required | Values | Used By |
|
||||||
|-----|----------|--------|---------|
|
|-----|----------|--------|---------|
|
||||||
| `MinigameId` | Recommended | Minigame ID | Filters spawns to the current runtime's minigame |
|
| `MinigameId` | Recommended | Minigame ID | Filters spawns to the current runtime's minigame |
|
||||||
| `MapId` | Recommended | Map ID | Filters spawns to the current runtime's map |
|
| `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 |
|
| `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 |
|
| `SpawnRole` | Yes | `PlayerSpawn` or `TeamSpawn` | Marks the volume as a 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` |
|
| `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
|
```text
|
||||||
MinigameId=KOTH
|
MinigameId=KOTH
|
||||||
@@ -53,7 +61,7 @@ SpawnRole=TeamSpawn
|
|||||||
TeamId=red
|
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.
|
`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
@@ -22,6 +22,50 @@ Overlapping volumes are valid. Runtime routing uses the selected `MinigameId`, `
|
|||||||
|
|
||||||
## Common Patterns
|
## 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
|
### Start Zone
|
||||||
|
|
||||||
Use a normal trigger volume at an entrance, sign, NPC, or lobby pad.
|
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`
|
- Condition: `MinigamePhase`
|
||||||
- Field: `Phase = ACTIVE`
|
- Field: `Phase = ACTIVE`
|
||||||
|
|
||||||
### Team Spawns
|
### Player And Team Spawns
|
||||||
|
|
||||||
Use normal trigger volumes as spawn markers. The volume position is the teleport destination.
|
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>`
|
- `MinigameId=<your minigame>`
|
||||||
- `MapId=<map id>`
|
- `MapId=<map id>`
|
||||||
@@ -87,10 +138,11 @@ Tag each team spawn volume with:
|
|||||||
1. The effect's explicit `DestinationId`.
|
1. The effect's explicit `DestinationId`.
|
||||||
2. The player's stored checkpoint from `SetSpawnPoint`.
|
2. The player's stored checkpoint from `SetSpawnPoint`.
|
||||||
3. A random matching team spawn volume for the player's `TeamId`.
|
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
|
### Counters
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
|||||||
import com.hypixel.hytale.component.ComponentType;
|
import com.hypixel.hytale.component.ComponentType;
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
import com.hypixel.hytale.server.core.io.ServerManager;
|
import com.hypixel.hytale.server.core.io.ServerManager;
|
||||||
|
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.Interaction;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
import com.hypixel.hytale.logger.HytaleLogger;
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
import com.hypixel.hytale.server.core.asset.HytaleAssetStore;
|
import com.hypixel.hytale.server.core.asset.HytaleAssetStore;
|
||||||
@@ -17,25 +18,39 @@ import com.hypixel.hytale.server.core.plugin.JavaPluginInit;
|
|||||||
import com.hypixel.hytale.server.npc.NPCPlugin;
|
import com.hypixel.hytale.server.npc.NPCPlugin;
|
||||||
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
||||||
import net.kewwbec.minigames.command.MinigameCommand;
|
import net.kewwbec.minigames.command.MinigameCommand;
|
||||||
|
import net.kewwbec.minigames.interaction.MinigameInteractions;
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.model.MinigameVolumeTemplate;
|
||||||
import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService;
|
import net.kewwbec.minigames.runtime.DefaultMinigameRuntimeService;
|
||||||
import net.kewwbec.minigames.service.DefaultMinigameService;
|
import net.kewwbec.minigames.service.DefaultMinigameService;
|
||||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||||
|
import net.kewwbec.minigames.stats.MinigameStatsDamageSystem;
|
||||||
|
import net.kewwbec.minigames.stats.MinigameStatsDeathSystem;
|
||||||
|
import net.kewwbec.minigames.stats.MinigameStatsListener;
|
||||||
|
import net.kewwbec.minigames.stats.MinigameStatsService;
|
||||||
|
import net.kewwbec.minigames.stats.db.MinigameStatsRepository;
|
||||||
|
import net.kewwbec.minigames.stats.db.SqliteStatsDatabase;
|
||||||
|
import net.kewwbec.minigames.stats.db.StatsDatabase;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameEvent;
|
||||||
import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent;
|
import com.hypixel.hytale.server.core.event.events.player.PlayerConnectEvent;
|
||||||
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent;
|
||||||
import net.kewwbec.minigames.system.MinigameConnectionListener;
|
import net.kewwbec.minigames.system.MinigameConnectionListener;
|
||||||
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
import net.kewwbec.minigames.system.MinigameDeathRespawnSystem;
|
||||||
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
|
import net.kewwbec.minigames.system.MinigameKillScoreSystem;
|
||||||
|
import net.kewwbec.minigames.system.MinigameLeaderboardUISystem;
|
||||||
import net.kewwbec.minigames.system.MinigamePregameSystem;
|
import net.kewwbec.minigames.system.MinigamePregameSystem;
|
||||||
import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems;
|
import net.kewwbec.minigames.system.MinigameRuleEnforcementSystems;
|
||||||
import net.kewwbec.minigames.ui.MinigameHudService;
|
import net.kewwbec.minigames.ui.MinigameHudService;
|
||||||
import net.kewwbec.minigames.ui.MinigameHudSystem;
|
import net.kewwbec.minigames.ui.MinigameHudSystem;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||||
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
|
import net.kewwbec.minigames.ui.MinigameMenuOpenSystem;
|
||||||
import net.kewwbec.minigames.ui.MinigameMenuService;
|
import net.kewwbec.minigames.ui.MinigameMenuService;
|
||||||
import net.kewwbec.minigames.ui.MinigameQueueUIService;
|
import net.kewwbec.minigames.ui.MinigameQueueUIService;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameTemplateBuilderDataStore;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameTemplateBuilderUIService;
|
||||||
import net.kewwbec.minigames.system.MinigameQueueUISystem;
|
import net.kewwbec.minigames.system.MinigameQueueUISystem;
|
||||||
import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem;
|
import net.kewwbec.minigames.system.TriggerVolumeDuplicateSystem;
|
||||||
import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect;
|
import net.kewwbec.minigames.volume.effect.OpenMinigameQueueUIEffect;
|
||||||
@@ -96,6 +111,9 @@ import com.hypixel.hytale.server.core.io.adapter.PacketAdapters;
|
|||||||
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
|
import com.hypixel.hytale.server.core.io.adapter.PacketFilter;
|
||||||
import net.kewwbec.minigames.command.TriggerVolumeCloneCommand;
|
import net.kewwbec.minigames.command.TriggerVolumeCloneCommand;
|
||||||
import net.kewwbec.minigames.command.TriggerVolumeCloneGroupCommand;
|
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.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
@@ -108,10 +126,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
private static MinigameCorePlugin instance;
|
private static MinigameCorePlugin instance;
|
||||||
private MinigameServices services;
|
private MinigameServices services;
|
||||||
private MinigameQueueUIService queueUIService;
|
private MinigameQueueUIService queueUIService;
|
||||||
|
private MinigameLeaderboardUIService leaderboardUIService;
|
||||||
|
private MinigameTemplateBuilderUIService templateBuilderUIService;
|
||||||
|
private StatsDatabase statsDatabase;
|
||||||
private PacketFilter duplicatePacketFilter;
|
private PacketFilter duplicatePacketFilter;
|
||||||
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
private ComponentType<EntityStore, LockMountComponent> lockMountComponentType;
|
||||||
private final java.util.List<String> ownEffectTypeIds = new java.util.ArrayList<>();
|
private final java.util.List<String> ownEffectTypeIds = new java.util.ArrayList<>();
|
||||||
private final java.util.List<String> ownConditionTypeIds = new java.util.ArrayList<>();
|
private final java.util.List<String> ownConditionTypeIds = new java.util.ArrayList<>();
|
||||||
|
private final java.util.List<String> ownInteractionTypeIds = new java.util.ArrayList<>();
|
||||||
|
|
||||||
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
public MinigameCorePlugin(@Nonnull JavaPluginInit init) {
|
||||||
super(init);
|
super(init);
|
||||||
@@ -128,6 +150,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
this.duplicatePacketFilter = duplicateSystem.register();
|
this.duplicatePacketFilter = duplicateSystem.register();
|
||||||
registerTriggerVolumeEffectTypes();
|
registerTriggerVolumeEffectTypes();
|
||||||
registerTriggerVolumeConditionTypes();
|
registerTriggerVolumeConditionTypes();
|
||||||
|
registerMinigameInteractionTypes();
|
||||||
|
|
||||||
AssetRegistry.register(
|
AssetRegistry.register(
|
||||||
HytaleAssetStore.builder(MinigameDefinition.class, new DefaultAssetMap<String, MinigameDefinition>())
|
HytaleAssetStore.builder(MinigameDefinition.class, new DefaultAssetMap<String, MinigameDefinition>())
|
||||||
@@ -137,6 +160,14 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
.loadsAfter(TriggerEffectAsset.class)
|
.loadsAfter(TriggerEffectAsset.class)
|
||||||
.build()
|
.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 runtimeService = new DefaultMinigameRuntimeService();
|
||||||
var minigameService = new DefaultMinigameService(runtimeService);
|
var minigameService = new DefaultMinigameService(runtimeService);
|
||||||
@@ -155,12 +186,22 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService));
|
getEntityStoreRegistry().registerSystem(new MinigameHudSystem(hudService));
|
||||||
var queueService = new MinigameQueueService();
|
var queueService = new MinigameQueueService();
|
||||||
runtimeService.setQueueService(queueService);
|
runtimeService.setQueueService(queueService);
|
||||||
|
var statsService = setupStats(adapters);
|
||||||
this.services = new MinigameServices(minigameService, runtimeService, adapters,
|
this.services = new MinigameServices(minigameService, runtimeService, adapters,
|
||||||
new MinigameMapDiscoveryService(), queueService, signals, menuService);
|
new MinigameMapDiscoveryService(), queueService, signals, menuService, statsService);
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameStatsDeathSystem());
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameStatsDamageSystem());
|
||||||
|
var statsListener = new MinigameStatsListener(statsService, runtimeService);
|
||||||
|
getEventRegistry().registerGlobal(MinigameEvent.class, statsListener::onMinigameEvent);
|
||||||
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(services));
|
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(services));
|
||||||
this.queueUIService = new MinigameQueueUIService(services);
|
this.queueUIService = new MinigameQueueUIService(services);
|
||||||
this.queueUIService.setPlayerAdapter(adapters);
|
this.queueUIService.setPlayerAdapter(adapters);
|
||||||
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
|
getEntityStoreRegistry().registerSystem(new MinigameQueueUISystem(queueUIService));
|
||||||
|
this.leaderboardUIService = new MinigameLeaderboardUIService(services);
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameLeaderboardUISystem(leaderboardUIService));
|
||||||
|
this.templateBuilderUIService =
|
||||||
|
new MinigameTemplateBuilderUIService(new MinigameTemplateBuilderDataStore(getDataDirectory()));
|
||||||
|
this.templateBuilderUIService.setPlayerAdapter(adapters);
|
||||||
|
|
||||||
// Definition-flag enforcement (AllowPvp / AllowBlockBreaking / AllowBlockPlacing)
|
// Definition-flag enforcement (AllowPvp / AllowBlockBreaking / AllowBlockPlacing)
|
||||||
getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.PvpGuard());
|
getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.PvpGuard());
|
||||||
@@ -174,10 +215,33 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
|
|
||||||
registerTriggerVolumeAssetSources();
|
registerTriggerVolumeAssetSources();
|
||||||
|
|
||||||
this.getCommandRegistry().registerCommand(new MinigameCommand(services));
|
this.getCommandRegistry().registerCommand(new MinigameCommand(services, leaderboardUIService));
|
||||||
injectTriggerVolumeCloneCommand();
|
injectTriggerVolumeCloneCommand();
|
||||||
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
|
LOGGER.atInfo().log("core-minigames registered " + registeredTriggerConditionCount()
|
||||||
+ " trigger conditions and " + registeredTriggerEffectCount() + " trigger effects.");
|
+ " trigger conditions, " + registeredTriggerEffectCount()
|
||||||
|
+ " trigger effects, and " + registeredInteractionCount() + " interactions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the stats service. The SQLite store lives under the plugin data directory now; the
|
||||||
|
* pivot to the network-wide MySQL pool only swaps the {@link StatsDatabase} implementation here.
|
||||||
|
* A database failure degrades gracefully: stats are still tracked in memory, just not persisted.
|
||||||
|
*/
|
||||||
|
private MinigameStatsService setupStats(@Nonnull HytaleServerAdapters adapters) {
|
||||||
|
MinigameStatsRepository repository = null;
|
||||||
|
try {
|
||||||
|
java.nio.file.Path dir = getDataDirectory();
|
||||||
|
java.nio.file.Files.createDirectories(dir);
|
||||||
|
String url = "jdbc:sqlite:" + dir.resolve("minigame-stats.db");
|
||||||
|
SqliteStatsDatabase db = new SqliteStatsDatabase(url);
|
||||||
|
db.start();
|
||||||
|
this.statsDatabase = db;
|
||||||
|
repository = new MinigameStatsRepository(db);
|
||||||
|
} catch (Exception e) {
|
||||||
|
((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e))
|
||||||
|
.log("Failed to initialize minigame stats database; stats will be tracked but not persisted");
|
||||||
|
}
|
||||||
|
return new MinigameStatsService(repository, adapters::getDisplayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -186,11 +250,18 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown");
|
services.runtime().shutdownAll("minigames.runtime.reason.plugin_shutdown");
|
||||||
services.signals().shutdown();
|
services.signals().shutdown();
|
||||||
}
|
}
|
||||||
|
if (statsDatabase != null) {
|
||||||
|
statsDatabase.close();
|
||||||
|
statsDatabase = null;
|
||||||
|
}
|
||||||
if (duplicatePacketFilter != null) {
|
if (duplicatePacketFilter != null) {
|
||||||
PacketAdapters.deregisterInbound(duplicatePacketFilter);
|
PacketAdapters.deregisterInbound(duplicatePacketFilter);
|
||||||
duplicatePacketFilter = null;
|
duplicatePacketFilter = null;
|
||||||
}
|
}
|
||||||
services = null;
|
services = null;
|
||||||
|
queueUIService = null;
|
||||||
|
leaderboardUIService = null;
|
||||||
|
templateBuilderUIService = null;
|
||||||
instance = null;
|
instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +275,16 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
return instance != null ? instance.queueUIService : null;
|
return instance != null ? instance.queueUIService : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static MinigameLeaderboardUIService getLeaderboardUIService() {
|
||||||
|
return instance != null ? instance.leaderboardUIService : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static MinigameTemplateBuilderUIService getTemplateBuilderUIService() {
|
||||||
|
return instance != null ? instance.templateBuilderUIService : null;
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
||||||
MinigameCorePlugin plugin = instance;
|
MinigameCorePlugin plugin = instance;
|
||||||
@@ -231,13 +312,25 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
TriggerVolumeCloneGroupCommand cloneGroupCommand = new TriggerVolumeCloneGroupCommand();
|
TriggerVolumeCloneGroupCommand cloneGroupCommand = new TriggerVolumeCloneGroupCommand();
|
||||||
tvCommand.addSubCommand(cloneGroupCommand);
|
tvCommand.addSubCommand(cloneGroupCommand);
|
||||||
cloneGroupCommand.completeRegistration();
|
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);
|
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) {
|
} catch (Exception e) {
|
||||||
// Reflection into CommandManager internals: breaks silently on server updates,
|
// Reflection into CommandManager internals: breaks silently on server updates,
|
||||||
// so make the health check unmissable for admins reading the log.
|
// so make the health check unmissable for admins reading the log.
|
||||||
LOGGER.at(Level.SEVERE).withCause(e).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.");
|
+ "The server build likely changed CommandManager internals; core-minigames needs an update.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +394,51 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void registerMinigameInteractionTypes() {
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StartMinigame.TYPE_ID, MinigameInteractions.StartMinigame.class, MinigameInteractions.StartMinigame.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.EndMinigame.TYPE_ID, MinigameInteractions.EndMinigame.class, MinigameInteractions.EndMinigame.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ResetMinigame.TYPE_ID, MinigameInteractions.ResetMinigame.class, MinigameInteractions.ResetMinigame.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetMinigamePhase.TYPE_ID, MinigameInteractions.SetMinigamePhase.class, MinigameInteractions.SetMinigamePhase.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ResetScores.TYPE_ID, MinigameInteractions.ResetScores.class, MinigameInteractions.ResetScores.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ModifyScore.TYPE_ID, MinigameInteractions.ModifyScore.class, MinigameInteractions.ModifyScore.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ModifyCounter.TYPE_ID, MinigameInteractions.ModifyCounter.class, MinigameInteractions.ModifyCounter.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ModifyLives.TYPE_ID, MinigameInteractions.ModifyLives.class, MinigameInteractions.ModifyLives.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetPlayerStatus.TYPE_ID, MinigameInteractions.SetPlayerStatus.class, MinigameInteractions.SetPlayerStatus.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetPlayerLoadout.TYPE_ID, MinigameInteractions.SetPlayerLoadout.class, MinigameInteractions.SetPlayerLoadout.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.JoinMinigameQueue.TYPE_ID, MinigameInteractions.JoinMinigameQueue.class, MinigameInteractions.JoinMinigameQueue.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.LeaveMinigameQueue.TYPE_ID, MinigameInteractions.LeaveMinigameQueue.class, MinigameInteractions.LeaveMinigameQueue.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.VoteForMap.TYPE_ID, MinigameInteractions.VoteForMap.class, MinigameInteractions.VoteForMap.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StartQueuedMinigame.TYPE_ID, MinigameInteractions.StartQueuedMinigame.class, MinigameInteractions.StartQueuedMinigame.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetRound.TYPE_ID, MinigameInteractions.SetRound.class, MinigameInteractions.SetRound.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StartTimer.TYPE_ID, MinigameInteractions.StartTimer.class, MinigameInteractions.StartTimer.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StopTimer.TYPE_ID, MinigameInteractions.StopTimer.class, MinigameInteractions.StopTimer.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetSpawnPoint.TYPE_ID, MinigameInteractions.SetSpawnPoint.class, MinigameInteractions.SetSpawnPoint.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.RespawnPlayer.TYPE_ID, MinigameInteractions.RespawnPlayer.class, MinigameInteractions.RespawnPlayer.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.RespawnAllPlayers.TYPE_ID, MinigameInteractions.RespawnAllPlayers.class, MinigameInteractions.RespawnAllPlayers.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SwapTeams.TYPE_ID, MinigameInteractions.SwapTeams.class, MinigameInteractions.SwapTeams.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SendMinigameMessage.TYPE_ID, MinigameInteractions.SendMinigameMessage.class, MinigameInteractions.SendMinigameMessage.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.AssignTeam.TYPE_ID, MinigameInteractions.AssignTeam.class, MinigameInteractions.AssignTeam.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SaveInventory.TYPE_ID, MinigameInteractions.SaveInventory.class, MinigameInteractions.SaveInventory.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.RestoreInventory.TYPE_ID, MinigameInteractions.RestoreInventory.class, MinigameInteractions.RestoreInventory.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ClearInventory.TYPE_ID, MinigameInteractions.ClearInventory.class, MinigameInteractions.ClearInventory.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.SetObjectiveStatus.TYPE_ID, MinigameInteractions.SetObjectiveStatus.class, MinigameInteractions.SetObjectiveStatus.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.ModifyObjectiveProgress.TYPE_ID, MinigameInteractions.ModifyObjectiveProgress.class, MinigameInteractions.ModifyObjectiveProgress.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.OpenMinigameQueueUI.TYPE_ID, MinigameInteractions.OpenMinigameQueueUI.class, MinigameInteractions.OpenMinigameQueueUI.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StartPlayerTimer.TYPE_ID, MinigameInteractions.StartPlayerTimer.class, MinigameInteractions.StartPlayerTimer.CODEC);
|
||||||
|
registerMinigameInteractionType(MinigameInteractions.StopPlayerTimer.TYPE_ID, MinigameInteractions.StopPlayerTimer.class, MinigameInteractions.StopPlayerTimer.CODEC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T extends Interaction> void registerMinigameInteractionType(
|
||||||
|
@Nonnull String typeId,
|
||||||
|
@Nonnull Class<T> clazz,
|
||||||
|
@Nonnull BuilderCodec<T> codec
|
||||||
|
) {
|
||||||
|
ownInteractionTypeIds.add(typeId);
|
||||||
|
if (Interaction.CODEC.getCodecFor(typeId) == null) {
|
||||||
|
getCodecRegistry(Interaction.CODEC).register(typeId, clazz, codec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void registerTriggerVolumeConditionTypes() {
|
private void registerTriggerVolumeConditionTypes() {
|
||||||
registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC);
|
registerTriggerVolumeConditionType(MinigamePhaseCondition.TYPE_ID, MinigamePhaseCondition.class, MinigamePhaseCondition.CODEC);
|
||||||
registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC);
|
registerTriggerVolumeConditionType(PlayerInMinigameCondition.TYPE_ID, PlayerInMinigameCondition.class, PlayerInMinigameCondition.CODEC);
|
||||||
@@ -334,6 +472,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
triggerVolumes.registerAssetSource("MinigameDefinition", () -> MinigameDefinition.getAssetMap().getAssetMap().keySet());
|
triggerVolumes.registerAssetSource("MinigameDefinition", () -> MinigameDefinition.getAssetMap().getAssetMap().keySet());
|
||||||
|
triggerVolumes.registerAssetSource("MinigameVolumeTemplate", () -> MinigameVolumeTemplate.getAssetMap().getAssetMap().keySet());
|
||||||
triggerVolumes.registerAssetSource("NpcRole", () -> {
|
triggerVolumes.registerAssetSource("NpcRole", () -> {
|
||||||
var npcPlugin = NPCPlugin.get();
|
var npcPlugin = NPCPlugin.get();
|
||||||
return npcPlugin != null ? npcPlugin.getRoleTemplateNames(true) : java.util.List.<String>of();
|
return npcPlugin != null ? npcPlugin.getRoleTemplateNames(true) : java.util.List.<String>of();
|
||||||
@@ -410,4 +549,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
var registered = TriggerCondition.CODEC.getRegisteredIds();
|
var registered = TriggerCondition.CODEC.getRegisteredIds();
|
||||||
return (int) ownConditionTypeIds.stream().filter(registered::contains).count();
|
return (int) ownConditionTypeIds.stream().filter(registered::contains).count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int registeredInteractionCount() {
|
||||||
|
var registered = Interaction.CODEC.getRegisteredIds();
|
||||||
|
return (int) ownInteractionTypeIds.stream().filter(registered::contains).count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package net.kewwbec.minigames.action;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public interface MinigameActionContext {
|
||||||
|
@Nonnull
|
||||||
|
Ref<EntityStore> entityRef();
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
Store<EntityStore> store();
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
String tagValue(@Nonnull String key);
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
default MinigameMapCandidate candidate(@Nonnull String fallbackMinigameId) {
|
||||||
|
String minigameId = clean(tagValue(MinigameMapDiscoveryService.TAG_MINIGAME_ID));
|
||||||
|
if (minigameId.isBlank()) {
|
||||||
|
minigameId = clean(fallbackMinigameId);
|
||||||
|
}
|
||||||
|
return new MinigameMapCandidate(
|
||||||
|
minigameId,
|
||||||
|
clean(tagValue(MinigameMapDiscoveryService.TAG_MAP_ID)),
|
||||||
|
clean(tagValue(MinigameMapDiscoveryService.TAG_VARIANT_ID)),
|
||||||
|
1,
|
||||||
|
MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(clean(tagValue(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
static MinigameActionContext fromTrigger(@Nonnull TriggerContext context, @Nullable String configuredMinigameId) {
|
||||||
|
return new TriggerBacked(context, configuredMinigameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
static MinigameActionContext fromInteraction(
|
||||||
|
@Nonnull Ref<EntityStore> entityRef,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nullable String minigameId,
|
||||||
|
@Nullable String mapId,
|
||||||
|
@Nullable String variantId,
|
||||||
|
@Nullable String teamId
|
||||||
|
) {
|
||||||
|
return new InteractionBacked(entityRef, store, minigameId, mapId, variantId, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
static String clean(@Nullable String value) {
|
||||||
|
return value != null && !value.isBlank() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TriggerBacked implements MinigameActionContext {
|
||||||
|
@Nonnull
|
||||||
|
private final TriggerContext context;
|
||||||
|
@Nullable
|
||||||
|
private final String configuredMinigameId;
|
||||||
|
|
||||||
|
private TriggerBacked(@Nonnull TriggerContext context, @Nullable String configuredMinigameId) {
|
||||||
|
this.context = context;
|
||||||
|
this.configuredMinigameId = configuredMinigameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Ref<EntityStore> entityRef() {
|
||||||
|
return context.getEntityRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Store<EntityStore> store() {
|
||||||
|
return context.getStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public String tagValue(@Nonnull String key) {
|
||||||
|
if (MinigameMapDiscoveryService.TAG_MINIGAME_ID.equals(key)
|
||||||
|
&& (context.getVolume() == null
|
||||||
|
|| MinigameActionContext.clean(context.getVolume().getRawTags().get(key)).isBlank())) {
|
||||||
|
return configuredMinigameId;
|
||||||
|
}
|
||||||
|
Map<String, String> tags = context.getVolume() != null ? context.getVolume().getRawTags() : Map.of();
|
||||||
|
return tags.get(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class InteractionBacked implements MinigameActionContext {
|
||||||
|
@Nonnull
|
||||||
|
private final Ref<EntityStore> entityRef;
|
||||||
|
@Nonnull
|
||||||
|
private final Store<EntityStore> store;
|
||||||
|
@Nullable
|
||||||
|
private final String minigameId;
|
||||||
|
@Nullable
|
||||||
|
private final String mapId;
|
||||||
|
@Nullable
|
||||||
|
private final String variantId;
|
||||||
|
@Nullable
|
||||||
|
private final String teamId;
|
||||||
|
|
||||||
|
private InteractionBacked(
|
||||||
|
@Nonnull Ref<EntityStore> entityRef,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nullable String minigameId,
|
||||||
|
@Nullable String mapId,
|
||||||
|
@Nullable String variantId,
|
||||||
|
@Nullable String teamId
|
||||||
|
) {
|
||||||
|
this.entityRef = entityRef;
|
||||||
|
this.store = store;
|
||||||
|
this.minigameId = minigameId;
|
||||||
|
this.mapId = mapId;
|
||||||
|
this.variantId = variantId;
|
||||||
|
this.teamId = teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Ref<EntityStore> entityRef() {
|
||||||
|
return entityRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Store<EntityStore> store() {
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public String tagValue(@Nonnull String key) {
|
||||||
|
return switch (key) {
|
||||||
|
case MinigameMapDiscoveryService.TAG_MINIGAME_ID -> minigameId;
|
||||||
|
case MinigameMapDiscoveryService.TAG_MAP_ID -> mapId;
|
||||||
|
case MinigameMapDiscoveryService.TAG_VARIANT_ID -> variantId;
|
||||||
|
case MinigameMapDiscoveryService.TAG_TEAM_ID -> teamId;
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package net.kewwbec.minigames.action;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class MinigameActionSupport {
|
||||||
|
private MinigameActionSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static MinigameServices services() {
|
||||||
|
return MinigameCorePlugin.getServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context, @Nullable String configuredMinigameId) {
|
||||||
|
MinigameServices services = services();
|
||||||
|
if (services == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
var candidate = context.candidate(configuredMinigameId != null ? configuredMinigameId : "");
|
||||||
|
String declaredId = !candidate.minigameId().isBlank()
|
||||||
|
? candidate.minigameId()
|
||||||
|
: (configuredMinigameId != null ? configuredMinigameId : "");
|
||||||
|
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
if (playerId != null) {
|
||||||
|
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||||
|
if (playerRuntime.isPresent()
|
||||||
|
&& (declaredId.isBlank() || playerRuntime.get().minigameId().equals(declaredId))) {
|
||||||
|
return playerRuntime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaredId.isBlank()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||||
|
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static String resolvedMinigameId(@Nonnull MinigameActionContext context, @Nullable String configuredMinigameId) {
|
||||||
|
var services = services();
|
||||||
|
if (services != null) {
|
||||||
|
var candidate = context.candidate(configuredMinigameId != null ? configuredMinigameId : "");
|
||||||
|
if (!candidate.minigameId().isBlank()) {
|
||||||
|
return candidate.minigameId();
|
||||||
|
}
|
||||||
|
if (configuredMinigameId == null || configuredMinigameId.isBlank()) {
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
if (playerId != null) {
|
||||||
|
var playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||||
|
if (playerRuntime.isPresent()) {
|
||||||
|
return playerRuntime.get().minigameId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return configuredMinigameId != null ? configuredMinigameId : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long debugMessage(
|
||||||
|
@Nonnull MinigameActionContext context,
|
||||||
|
@Nullable String configuredMinigameId,
|
||||||
|
@Nonnull String actionType,
|
||||||
|
@Nonnull String details,
|
||||||
|
long lastDebugMs
|
||||||
|
) {
|
||||||
|
String id = resolvedMinigameId(context, configuredMinigameId);
|
||||||
|
if (id.isBlank()) return lastDebugMs;
|
||||||
|
var def = MinigameDefinition.getAssetMap().getAsset(id);
|
||||||
|
if (def == null || !def.isDebug()) return lastDebugMs;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastDebugMs < 5000L) return lastDebugMs;
|
||||||
|
PlayerRef playerRef = context.store().getComponent(context.entityRef(), PlayerRef.getComponentType());
|
||||||
|
if (playerRef != null) {
|
||||||
|
playerRef.sendMessage(
|
||||||
|
Message.translation("minigames.debug.effect")
|
||||||
|
.param("type", actionType)
|
||||||
|
.param("details", details)
|
||||||
|
.color(Color.CYAN)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static String displayName(@Nonnull String playerId) {
|
||||||
|
MinigameServices services = services();
|
||||||
|
if (services != null) {
|
||||||
|
String name = services.adapters().getDisplayName(playerId);
|
||||||
|
if (name != null && !name.isBlank()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||||
|
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) {
|
||||||
|
return configuredPlayerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerRef playerRef = context.store().getComponent(context.entityRef(), PlayerRef.getComponentType());
|
||||||
|
if (playerRef != null) {
|
||||||
|
return playerRef.getUuid().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
UUIDComponent uuidComponent = context.store().getComponent(context.entityRef(), UUIDComponent.getComponentType());
|
||||||
|
return uuidComponent != null ? uuidComponent.getUuid().toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ public final class HytaleAdapters {
|
|||||||
String getDisplayName(String playerId);
|
String getDisplayName(String playerId);
|
||||||
void setGameMode(String playerId, GameMode gameMode);
|
void setGameMode(String playerId, GameMode gameMode);
|
||||||
void giveItem(String playerId, String itemId, int amount);
|
void giveItem(String playerId, String itemId, int amount);
|
||||||
|
|
||||||
|
/** Makes the player a true spectator: invulnerable, intangible (noclip), flying, and hidden from adventure players. */
|
||||||
|
void applySpectatorState(String playerId);
|
||||||
|
|
||||||
|
/** Reverts {@link #applySpectatorState}: removes invulnerability/intangibility/hiding and disables flight. */
|
||||||
|
void clearSpectatorState(String playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface WorldAdapter {
|
public interface WorldAdapter {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import com.hypixel.hytale.protocol.packets.inventory.UpdatePlayerInventory;
|
|||||||
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
|
import com.hypixel.hytale.server.core.inventory.InventoryComponent;
|
||||||
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
import com.hypixel.hytale.server.core.inventory.InventoryUtils;
|
||||||
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
import com.hypixel.hytale.server.core.inventory.ItemStack;
|
||||||
|
import com.hypixel.hytale.server.core.entity.entities.player.movement.MovementManager;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.component.HiddenFromAdventurePlayers;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.component.Intangible;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.component.Invulnerable;
|
||||||
import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport;
|
import com.hypixel.hytale.server.core.modules.entity.teleport.Teleport;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.Universe;
|
import com.hypixel.hytale.server.core.universe.Universe;
|
||||||
@@ -84,6 +88,48 @@ public final class HytaleServerAdapters implements
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void applySpectatorState(String playerId) {
|
||||||
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
|
var store = ref.getStore();
|
||||||
|
store.putComponent(ref, Invulnerable.getComponentType(), Invulnerable.INSTANCE);
|
||||||
|
store.putComponent(ref, Intangible.getComponentType(), Intangible.INSTANCE);
|
||||||
|
store.putComponent(ref, HiddenFromAdventurePlayers.getComponentType(), HiddenFromAdventurePlayers.INSTANCE);
|
||||||
|
setFlying(store, ref, true);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void clearSpectatorState(String playerId) {
|
||||||
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
|
var store = ref.getStore();
|
||||||
|
store.tryRemoveComponent(ref, Invulnerable.getComponentType());
|
||||||
|
store.tryRemoveComponent(ref, Intangible.getComponentType());
|
||||||
|
store.tryRemoveComponent(ref, HiddenFromAdventurePlayers.getComponentType());
|
||||||
|
setFlying(store, ref, false);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the player's flight capability, mirroring {@link Player#setGameMode}'s MovementManager handling. */
|
||||||
|
private void setFlying(com.hypixel.hytale.component.Store<EntityStore> store, Ref<EntityStore> ref, boolean canFly) {
|
||||||
|
MovementManager movement = store.getComponent(ref, MovementManager.getComponentType());
|
||||||
|
if (movement == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (movement.getDefaultSettings() != null) {
|
||||||
|
movement.getDefaultSettings().canFly = canFly;
|
||||||
|
}
|
||||||
|
if (movement.getSettings() != null) {
|
||||||
|
movement.getSettings().canFly = canFly;
|
||||||
|
}
|
||||||
|
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
|
if (playerRef != null) {
|
||||||
|
movement.update(playerRef.getPacketHandler());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void giveItem(String playerId, String itemId, int amount) {
|
public void giveItem(String playerId, String itemId, int amount) {
|
||||||
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
if (itemId == null || itemId.isBlank() || amount <= 0) return;
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package net.kewwbec.minigames.command;
|
|||||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
||||||
import net.kewwbec.minigames.command.sub.*;
|
import net.kewwbec.minigames.command.sub.*;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||||
|
|
||||||
public final class MinigameCommand extends AbstractCommandCollection {
|
public final class MinigameCommand extends AbstractCommandCollection {
|
||||||
public MinigameCommand(MinigameServices services) {
|
public MinigameCommand(MinigameServices services, MinigameLeaderboardUIService leaderboardUIService) {
|
||||||
super("minigame", "minigames.command.root.description");
|
super("minigame", "minigames.command.root.description");
|
||||||
this.setPermissionGroups("hytale:WorldEditor");
|
this.setPermissionGroups("hytale:WorldEditor");
|
||||||
|
|
||||||
@@ -25,5 +26,6 @@ public final class MinigameCommand extends AbstractCommandCollection {
|
|||||||
addSubCommand(new MinigameJoinCommand(services));
|
addSubCommand(new MinigameJoinCommand(services));
|
||||||
addSubCommand(new MinigameLeaveCommand(services));
|
addSubCommand(new MinigameLeaveCommand(services));
|
||||||
addSubCommand(new MinigameSpectateCommand(services));
|
addSubCommand(new MinigameSpectateCommand(services));
|
||||||
|
addSubCommand(new MinigameStatsCommand(services, leaderboardUIService));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -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())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package net.kewwbec.minigames.command.sub;
|
package net.kewwbec.minigames.command.sub;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
import com.hypixel.hytale.server.core.Message;
|
import com.hypixel.hytale.server.core.Message;
|
||||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
@@ -7,8 +8,12 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
|
|||||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -40,7 +45,17 @@ public final class MinigameJoinCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), target.getUuid().toString());
|
String playerId = target.getUuid().toString();
|
||||||
|
MinigameDefinition def = defOpt.get();
|
||||||
|
|
||||||
|
// A game is already running for this minigame: route to late-join / spectate instead of queueing.
|
||||||
|
var runtimes = services.runtime().runtimesForMinigame(id);
|
||||||
|
if (!runtimes.isEmpty()) {
|
||||||
|
joinRunning(ctx, def, runtimes.iterator().next(), target, playerId, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MinigameQueueService.JoinResult result = services.queue().join(def, playerId);
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id));
|
case JOINED -> ctx.sendMessage(Message.translation("minigames.command.join.success").param("id", id));
|
||||||
case ALREADY_QUEUED -> ctx.sendMessage(Message.translation("minigames.command.join.already_queued").param("id", id));
|
case ALREADY_QUEUED -> ctx.sendMessage(Message.translation("minigames.command.join.already_queued").param("id", id));
|
||||||
@@ -48,4 +63,52 @@ public final class MinigameJoinCommand extends CommandBase {
|
|||||||
case DISABLED -> ctx.sendMessage(Message.translation("minigames.queue.disabled").param("id", id));
|
case DISABLED -> ctx.sendMessage(Message.translation("minigames.queue.disabled").param("id", id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void joinRunning(CommandContext ctx, MinigameDefinition def, MinigameRuntime runtime,
|
||||||
|
PlayerRef target, String playerId, String id) {
|
||||||
|
if (services.runtime().runtimeForPlayer(playerId).isPresent()) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.isAllowJoinMidgame()) {
|
||||||
|
MinigameQueueService.JoinResult result = services.runtime().onPlayerJoinMidgame(runtime, playerId);
|
||||||
|
switch (result) {
|
||||||
|
case JOINED -> {
|
||||||
|
spawnAndEquip(target, runtime, playerId);
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.join.midgame").param("id", id));
|
||||||
|
}
|
||||||
|
case QUEUE_FULL -> ctx.sendMessage(Message.translation("minigames.queue.full").param("id", id));
|
||||||
|
default -> ctx.sendMessage(Message.translation("minigames.command.join.already_in_game").param("id", id));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.isAllowSpectators()) {
|
||||||
|
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||||
|
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.join.as_spectator").param("id", id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.join.in_progress").param("id", id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Late joiners miss the start/round volume triggers, so teleport + equip them explicitly. */
|
||||||
|
private void spawnAndEquip(PlayerRef target, MinigameRuntime runtime, String playerId) {
|
||||||
|
var player = runtime.players().get(playerId);
|
||||||
|
var ref = target.getReference();
|
||||||
|
Store<EntityStore> store = ref != null && ref.isValid() ? ref.getStore() : null;
|
||||||
|
|
||||||
|
String dest = player != null ? services.maps().respawnDestination(store, runtime, player, null) : "";
|
||||||
|
if (!dest.isBlank()) {
|
||||||
|
services.adapters().teleport(playerId, dest);
|
||||||
|
} else {
|
||||||
|
// No team / checkpoint spawn resolvable (e.g. FFA): fall back to the main arena volume.
|
||||||
|
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant the definition's default kit (StartItems) — mirrors a blank-loadout SetPlayerLoadout.
|
||||||
|
SetPlayerLoadoutEffect.grantLoadout(services, runtime, playerId, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package net.kewwbec.minigames.command.sub;
|
package net.kewwbec.minigames.command.sub;
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
|
||||||
import com.hypixel.hytale.component.Store;
|
|
||||||
import com.hypixel.hytale.server.core.Message;
|
import com.hypixel.hytale.server.core.Message;
|
||||||
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
@@ -11,16 +7,10 @@ import com.hypixel.hytale.server.core.command.system.arguments.system.RequiredAr
|
|||||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|
||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
|
||||||
|
|
||||||
public final class MinigameSpectateCommand extends CommandBase {
|
public final class MinigameSpectateCommand extends CommandBase {
|
||||||
private final MinigameServices services;
|
private final MinigameServices services;
|
||||||
@@ -67,52 +57,9 @@ public final class MinigameSpectateCommand extends CommandBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MinigameRuntime runtime = runtimes.iterator().next();
|
MinigameRuntime runtime = runtimes.iterator().next();
|
||||||
PlayerMinigameState state = runtime.players().computeIfAbsent(playerId,
|
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||||
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
|
||||||
state.status(PlayerStatus.SPECTATOR);
|
|
||||||
runtime.spectators().add(playerId);
|
|
||||||
|
|
||||||
teleportToArena(target, runtime);
|
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||||
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best effort: drop the spectator at the arena's MainArena volume if one is visible from their world. */
|
|
||||||
private void teleportToArena(PlayerRef target, MinigameRuntime runtime) {
|
|
||||||
var ref = target.getReference();
|
|
||||||
if (ref == null || !ref.isValid()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Store<EntityStore> store = ref.getStore();
|
|
||||||
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
|
||||||
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
|
|
||||||
if (manager == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
VolumeEntry arena = findMainArena(manager, runtime);
|
|
||||||
if (arena == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var pos = arena.getPosition();
|
|
||||||
String destination = pos.x() + "," + pos.y() + "," + pos.z();
|
|
||||||
services.adapters().teleport(target.getUuid().toString(), destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
|
|
||||||
for (VolumeEntry volume : manager.getVolumes()) {
|
|
||||||
var tags = volume.getRawTags();
|
|
||||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
|
|
||||||
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return volume;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package net.kewwbec.minigames.command.sub;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.CommandContext;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.system.OptionalArg;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
||||||
|
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
public final class MinigameStatsCommand extends CommandBase {
|
||||||
|
private final MinigameServices services;
|
||||||
|
private final MinigameLeaderboardUIService uiService;
|
||||||
|
private final OptionalArg<String> idArg;
|
||||||
|
|
||||||
|
public MinigameStatsCommand(MinigameServices services, MinigameLeaderboardUIService uiService) {
|
||||||
|
super("stats", "minigames.command.spec.stats.description");
|
||||||
|
this.services = services;
|
||||||
|
this.uiService = uiService;
|
||||||
|
this.idArg = withOptionalArg("id", "minigames.command.spec.stats.arg.id", ArgTypes.STRING);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||||
|
PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null;
|
||||||
|
if (player == null) {
|
||||||
|
ctx.sendMessage(Message.raw("[Minigames] The stats UI must be opened by a player."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String id = ctx.provided(idArg) ? ctx.get(idArg) : "";
|
||||||
|
if (!id.isBlank() && services.minigames().definition(id).isEmpty()) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uiService.requestOpen(player, id);
|
||||||
|
ctx.sendMessage(Message.raw("[Minigames] Opening stats UI."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package net.kewwbec.minigames.command.sub;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/** Shared helper for dropping a player at a runtime's MainArena volume (used by spectate + late join). */
|
||||||
|
final class SpectatorArenaSupport {
|
||||||
|
private SpectatorArenaSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best effort: drop the player at the arena's MainArena volume if one is visible from their world. */
|
||||||
|
static void teleportToArena(MinigameServices services, PlayerRef target, MinigameRuntime runtime) {
|
||||||
|
var ref = target.getReference();
|
||||||
|
if (ref == null || !ref.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Store<EntityStore> store = ref.getStore();
|
||||||
|
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||||
|
TriggerVolumeManager manager = tvPlugin != null ? store.getResource(tvPlugin.getManagerResourceType()) : null;
|
||||||
|
if (manager == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
VolumeEntry arena = findMainArena(manager, runtime);
|
||||||
|
if (arena == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var pos = arena.getPosition();
|
||||||
|
String destination = pos.x() + "," + pos.y() + "," + pos.z();
|
||||||
|
services.adapters().teleport(target.getUuid().toString(), destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static VolumeEntry findMainArena(TriggerVolumeManager manager, MinigameRuntime runtime) {
|
||||||
|
for (VolumeEntry volume : manager.getVolumes()) {
|
||||||
|
var tags = volume.getRawTags();
|
||||||
|
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(tags.get(MinigameMapDiscoveryService.TAG_MAP_ROLE))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String mapId = tags.getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, "");
|
||||||
|
if (!runtime.mapId().isBlank() && !mapId.isBlank() && !runtime.mapId().equals(mapId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return volume;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
|||||||
|
package net.kewwbec.minigames.interaction;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.codec.Codec;
|
||||||
|
import com.hypixel.hytale.codec.KeyedCodec;
|
||||||
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
|
import com.hypixel.hytale.component.CommandBuffer;
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.protocol.InteractionState;
|
||||||
|
import com.hypixel.hytale.protocol.InteractionType;
|
||||||
|
import com.hypixel.hytale.server.core.entity.InteractionContext;
|
||||||
|
import com.hypixel.hytale.server.core.modules.interaction.interaction.CooldownHandler;
|
||||||
|
import com.hypixel.hytale.server.core.modules.interaction.interaction.config.SimpleInstantInteraction;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.action.MinigameActionContext;
|
||||||
|
import net.kewwbec.minigames.action.MinigameActionSupport;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public abstract class MinigameRuntimeInteraction extends SimpleInstantInteraction {
|
||||||
|
@Nonnull
|
||||||
|
public static final BuilderCodec<MinigameRuntimeInteraction> MINIGAME_INTERACTION_BASE_CODEC =
|
||||||
|
BuilderCodec.abstractBuilder(MinigameRuntimeInteraction.class, SimpleInstantInteraction.CODEC)
|
||||||
|
.<String>appendInherited(
|
||||||
|
new KeyedCodec<>("MinigameId", Codec.STRING, false),
|
||||||
|
(interaction, value) -> interaction.minigameId = value,
|
||||||
|
interaction -> interaction.minigameId,
|
||||||
|
(interaction, parent) -> interaction.minigameId = parent.minigameId
|
||||||
|
)
|
||||||
|
.add()
|
||||||
|
.<String>appendInherited(
|
||||||
|
new KeyedCodec<>("MapId", Codec.STRING, false),
|
||||||
|
(interaction, value) -> interaction.mapId = value,
|
||||||
|
interaction -> interaction.mapId,
|
||||||
|
(interaction, parent) -> interaction.mapId = parent.mapId
|
||||||
|
)
|
||||||
|
.add()
|
||||||
|
.<String>appendInherited(
|
||||||
|
new KeyedCodec<>("VariantId", Codec.STRING, false),
|
||||||
|
(interaction, value) -> interaction.variantId = value,
|
||||||
|
interaction -> interaction.variantId,
|
||||||
|
(interaction, parent) -> interaction.variantId = parent.variantId
|
||||||
|
)
|
||||||
|
.add()
|
||||||
|
.<String>appendInherited(
|
||||||
|
new KeyedCodec<>("TeamId", Codec.STRING, false),
|
||||||
|
(interaction, value) -> interaction.teamId = value,
|
||||||
|
interaction -> interaction.teamId,
|
||||||
|
(interaction, parent) -> interaction.teamId = parent.teamId
|
||||||
|
)
|
||||||
|
.add()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
protected String minigameId;
|
||||||
|
@Nullable
|
||||||
|
protected String mapId;
|
||||||
|
@Nullable
|
||||||
|
protected String variantId;
|
||||||
|
@Nullable
|
||||||
|
protected String teamId;
|
||||||
|
|
||||||
|
private volatile long lastDebugMs = 0L;
|
||||||
|
|
||||||
|
protected MinigameRuntimeInteraction() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public MinigameRuntimeInteraction(@Nonnull String id) {
|
||||||
|
super(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final void firstRun(
|
||||||
|
@Nonnull InteractionType interactionType,
|
||||||
|
@Nonnull InteractionContext interactionContext,
|
||||||
|
@Nonnull CooldownHandler cooldownHandler
|
||||||
|
) {
|
||||||
|
MinigameActionContext context = actionContext(interactionContext);
|
||||||
|
if (context == null) {
|
||||||
|
interactionContext.getState().state = InteractionState.Failed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execute(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract void execute(@Nonnull MinigameActionContext context);
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
protected MinigameActionContext actionContext(@Nonnull InteractionContext context) {
|
||||||
|
CommandBuffer<EntityStore> commandBuffer = context.getCommandBuffer();
|
||||||
|
Ref<EntityStore> entityRef = context.getEntity();
|
||||||
|
if (commandBuffer == null || entityRef == null || !entityRef.isValid() || commandBuffer.getExternalData() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Store<EntityStore> store = commandBuffer.getExternalData().getStore();
|
||||||
|
if (store == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return MinigameActionContext.fromInteraction(entityRef, store, minigameId, mapId, variantId, teamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
|
||||||
|
return MinigameActionSupport.runtime(context, minigameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
protected static MinigameServices services() {
|
||||||
|
return MinigameActionSupport.services();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
protected String resolvedMinigameId(@Nonnull MinigameActionContext context) {
|
||||||
|
return MinigameActionSupport.resolvedMinigameId(context, minigameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void debugMessage(@Nonnull MinigameActionContext context, @Nonnull String actionType, @Nonnull String details) {
|
||||||
|
lastDebugMs = MinigameActionSupport.debugMessage(context, minigameId, actionType, details, lastDebugMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
protected static String displayName(@Nonnull String playerId) {
|
||||||
|
return MinigameActionSupport.displayName(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
protected static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||||
|
return MinigameActionSupport.resolvePlayerId(context, configuredPlayerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -357,6 +357,8 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playerAdapter != null) {
|
if (playerAdapter != null) {
|
||||||
|
// A reactivated spectator must lose the spectator presentation before becoming playable.
|
||||||
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
||||||
}
|
}
|
||||||
if (def.isShowHud() && hudService != null) {
|
if (def.isShowHud() && hudService != null) {
|
||||||
@@ -364,12 +366,72 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerSpectate(MinigameRuntime runtime, String playerId) {
|
||||||
|
var state = runtime.players().computeIfAbsent(playerId,
|
||||||
|
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||||
|
state.status(PlayerStatus.SPECTATOR);
|
||||||
|
runtime.spectators().add(playerId);
|
||||||
|
runtime.eliminatedPlayers().remove(playerId);
|
||||||
|
if (playerAdapter != null) {
|
||||||
|
playerAdapter.applySpectatorState(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MinigameQueueService.JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId) {
|
||||||
|
var existing = runtime.players().get(playerId);
|
||||||
|
if (existing != null && existing.status() != PlayerStatus.LEFT && existing.status() != PlayerStatus.SPECTATOR) {
|
||||||
|
return MinigameQueueService.JoinResult.ALREADY_QUEUED;
|
||||||
|
}
|
||||||
|
var def = runtime.definition();
|
||||||
|
long participants = runtime.players().values().stream()
|
||||||
|
.filter(p -> p.status() != PlayerStatus.LEFT && p.status() != PlayerStatus.SPECTATOR)
|
||||||
|
.count();
|
||||||
|
if (participants >= def.getMaxPlayers()) {
|
||||||
|
return MinigameQueueService.JoinResult.QUEUE_FULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
var player = runtime.players().computeIfAbsent(playerId,
|
||||||
|
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||||
|
player.lives(Math.max(0, def.getDefaultPlayerLives()));
|
||||||
|
player.status(PlayerStatus.ACTIVE);
|
||||||
|
runtime.spectators().remove(playerId);
|
||||||
|
runtime.eliminatedPlayers().remove(playerId);
|
||||||
|
if (def.getTeamMode() == TeamMode.TEAMS) {
|
||||||
|
assignToSmallestTeam(runtime, playerId);
|
||||||
|
}
|
||||||
|
onPlayerActivated(runtime, playerId);
|
||||||
|
|
||||||
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
|
payload.put("player_id", playerId);
|
||||||
|
dispatch(new MinigameEvent("on_player_joined_midgame", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
|
return MinigameQueueService.JoinResult.JOINED;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Places a late joiner on the team with the fewest players (vs. setupTeams which reshuffles everyone). */
|
||||||
|
private void assignToSmallestTeam(MinigameRuntime runtime, String playerId) {
|
||||||
|
List<TeamState> teams = ensureTeams(runtime);
|
||||||
|
if (teams.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TeamState smallest = teams.get(0);
|
||||||
|
for (TeamState team : teams) {
|
||||||
|
if (team.players().size() < smallest.players().size()) {
|
||||||
|
smallest = team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.players().get(playerId).teamId(smallest.teamId());
|
||||||
|
smallest.players().add(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||||
var player = runtime.players().get(playerId);
|
var player = runtime.players().get(playerId);
|
||||||
if (player == null || player.status() == PlayerStatus.LEFT) {
|
if (player == null || player.status() == PlayerStatus.LEFT) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
boolean wasSpectator = runtime.spectators().contains(playerId);
|
||||||
player.status(PlayerStatus.LEFT);
|
player.status(PlayerStatus.LEFT);
|
||||||
if (player.teamId() != null) {
|
if (player.teamId() != null) {
|
||||||
var team = runtime.teams().get(player.teamId());
|
var team = runtime.teams().get(player.teamId());
|
||||||
@@ -378,6 +440,9 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime.spectators().remove(playerId);
|
runtime.spectators().remove(playerId);
|
||||||
|
if (wasSpectator && playerAdapter != null) {
|
||||||
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
|
}
|
||||||
if (hudService != null) {
|
if (hudService != null) {
|
||||||
hudService.removeForPlayer(playerId);
|
hudService.removeForPlayer(playerId);
|
||||||
}
|
}
|
||||||
@@ -573,6 +638,12 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
// Explicitly zero scores before clearing maps so any retained state object is not left with stale points.
|
// Explicitly zero scores before clearing maps so any retained state object is not left with stale points.
|
||||||
runtime.players().values().forEach(player -> player.score(0));
|
runtime.players().values().forEach(player -> player.score(0));
|
||||||
runtime.teams().values().forEach(team -> team.score(0));
|
runtime.teams().values().forEach(team -> team.score(0));
|
||||||
|
// Strip spectator presentation before dropping the set so invuln/noclip/flight/hidden don't persist.
|
||||||
|
if (playerAdapter != null) {
|
||||||
|
for (String spectatorId : runtime.spectators()) {
|
||||||
|
playerAdapter.clearSpectatorState(spectatorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
runtime.players().clear();
|
runtime.players().clear();
|
||||||
runtime.teams().clear();
|
runtime.teams().clear();
|
||||||
runtime.objectives().clear();
|
runtime.objectives().clear();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.runtime;
|
|||||||
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
|
import net.kewwbec.minigames.service.MinigameQueueService.JoinResult;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -42,6 +43,21 @@ public interface MinigameRuntimeService {
|
|||||||
|
|
||||||
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes the player a spectator of the runtime: sets {@code SPECTATOR} status, registers them in
|
||||||
|
* {@code runtime.spectators()}, and applies the spectator presentation (invulnerable / noclip /
|
||||||
|
* flight / hidden). The caller is responsible for the initial teleport.
|
||||||
|
*/
|
||||||
|
void onPlayerSpectate(MinigameRuntime runtime, String playerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a brand-new participant to an already-running runtime as {@code ACTIVE}. Honors the
|
||||||
|
* runtime's player cap and (for team modes) balances them onto the smallest team. Does not
|
||||||
|
* teleport or equip the player — the caller handles spawn + loadout. Returns {@code JOINED},
|
||||||
|
* {@code ALREADY_QUEUED} (already in a game) or {@code QUEUE_FULL}.
|
||||||
|
*/
|
||||||
|
JoinResult onPlayerJoinMidgame(MinigameRuntime runtime, String playerId);
|
||||||
|
|
||||||
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
||||||
void setupTeams(MinigameRuntime runtime);
|
void setupTeams(MinigameRuntime runtime);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public final class MinigameMapDiscoveryService {
|
|||||||
public static final String TAG_VARIANT_VOTABLE = "VariantVotable";
|
public static final String TAG_VARIANT_VOTABLE = "VariantVotable";
|
||||||
public static final String ROLE_MAIN_ARENA = "MainArena";
|
public static final String ROLE_MAIN_ARENA = "MainArena";
|
||||||
public static final String ROLE_TEAM_SPAWN = "TeamSpawn";
|
public static final String ROLE_TEAM_SPAWN = "TeamSpawn";
|
||||||
|
public static final String ROLE_PLAYER_SPAWN = "PlayerSpawn";
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public MinigameMapDiscoveryResult discover(@Nullable TriggerContext context, @Nonnull String minigameId) {
|
public MinigameMapDiscoveryResult discover(@Nullable TriggerContext context, @Nonnull String minigameId) {
|
||||||
@@ -120,6 +121,36 @@ public final class MinigameMapDiscoveryService {
|
|||||||
@Nonnull PlayerMinigameState player,
|
@Nonnull PlayerMinigameState player,
|
||||||
@Nullable String destinationOverride
|
@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);
|
String destination = clean(destinationOverride);
|
||||||
if (!destination.isBlank()) {
|
if (!destination.isBlank()) {
|
||||||
return destination;
|
return destination;
|
||||||
@@ -129,12 +160,6 @@ public final class MinigameMapDiscoveryService {
|
|||||||
if (!destination.isBlank()) {
|
if (!destination.isBlank()) {
|
||||||
return destination;
|
return destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
String teamId = clean(player.teamId());
|
|
||||||
if (!teamId.isBlank()) {
|
|
||||||
return teamSpawnDestination(store, runtime, teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,14 +169,43 @@ public final class MinigameMapDiscoveryService {
|
|||||||
if (manager == null || teamId.isBlank()) {
|
if (manager == null || teamId.isBlank()) {
|
||||||
return "";
|
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()
|
List<VolumeEntry> candidates = manager.getVolumes().stream()
|
||||||
.filter(volume -> isTeamSpawn(volume, runtime, teamId))
|
.filter(volume -> isTeamSpawn(volume, runtime, teamId))
|
||||||
.toList();
|
.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()) {
|
if (candidates.isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
VolumeEntry selected = candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
|
VolumeEntry selected = candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
|
||||||
var pos = selected.getPosition();
|
var pos = selected.getPosition();
|
||||||
if (selected.getWorldName() != null && !selected.getWorldName().isBlank()) {
|
if (selected.getWorldName() != null && !selected.getWorldName().isBlank()) {
|
||||||
@@ -167,9 +221,28 @@ public final class MinigameMapDiscoveryService {
|
|||||||
String variantId = clean(tags.get(TAG_VARIANT_ID));
|
String variantId = clean(tags.get(TAG_VARIANT_ID));
|
||||||
String spawnRole = clean(tags.get(TAG_SPAWN_ROLE));
|
String spawnRole = clean(tags.get(TAG_SPAWN_ROLE));
|
||||||
String spawnTeamId = clean(tags.get(TAG_TEAM_ID));
|
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 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())) {
|
if (!minigameId.isBlank() && !minigameId.equals(runtime.minigameId())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.service;
|
|||||||
|
|
||||||
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
import net.kewwbec.minigames.adapter.HytaleServerAdapters;
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||||
|
import net.kewwbec.minigames.stats.MinigameStatsService;
|
||||||
import net.kewwbec.minigames.ui.MinigameMenuService;
|
import net.kewwbec.minigames.ui.MinigameMenuService;
|
||||||
|
|
||||||
public record MinigameServices(
|
public record MinigameServices(
|
||||||
@@ -11,6 +12,7 @@ public record MinigameServices(
|
|||||||
MinigameMapDiscoveryService maps,
|
MinigameMapDiscoveryService maps,
|
||||||
MinigameQueueService queue,
|
MinigameQueueService queue,
|
||||||
MinigameVolumeSignalService signals,
|
MinigameVolumeSignalService signals,
|
||||||
MinigameMenuService menus
|
MinigameMenuService menus,
|
||||||
|
MinigameStatsService stats
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory record of everything tracked during one running game. One session exists per active
|
||||||
|
* runtime; it is created at game start and discarded once persisted at game end.
|
||||||
|
*/
|
||||||
|
public final class GameStatsSession {
|
||||||
|
private final String gameId;
|
||||||
|
private final String runtimeId;
|
||||||
|
private final String minigameId;
|
||||||
|
private final String mapId;
|
||||||
|
private final String variantId;
|
||||||
|
private final Instant startedAt;
|
||||||
|
private int roundCount;
|
||||||
|
private final Map<String, PlayerStatAccumulator> players = new LinkedHashMap<>();
|
||||||
|
private final Map<String, Object> attributes = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public GameStatsSession(String gameId, String runtimeId, String minigameId, String mapId, String variantId, Instant startedAt) {
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.runtimeId = runtimeId;
|
||||||
|
this.minigameId = minigameId;
|
||||||
|
this.mapId = mapId;
|
||||||
|
this.variantId = variantId;
|
||||||
|
this.startedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String gameId() { return gameId; }
|
||||||
|
public String runtimeId() { return runtimeId; }
|
||||||
|
public String minigameId() { return minigameId; }
|
||||||
|
public String mapId() { return mapId; }
|
||||||
|
public String variantId() { return variantId; }
|
||||||
|
public Instant startedAt() { return startedAt; }
|
||||||
|
|
||||||
|
public int roundCount() { return roundCount; }
|
||||||
|
public void roundCount(int roundCount) { this.roundCount = roundCount; }
|
||||||
|
|
||||||
|
/** Get-or-create the accumulator for a player. */
|
||||||
|
public PlayerStatAccumulator player(String playerId) {
|
||||||
|
return players.computeIfAbsent(playerId, PlayerStatAccumulator::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, PlayerStatAccumulator> players() {
|
||||||
|
return players;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Game-level extras (winner, end reason, custom game-wide tallies). */
|
||||||
|
public Map<String, Object> attributes() {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.ArchetypeChunk;
|
||||||
|
import com.hypixel.hytale.component.CommandBuffer;
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.component.SystemGroup;
|
||||||
|
import com.hypixel.hytale.component.query.Query;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.DamageEventSystem;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.DamageModule;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds the assist heuristic: every time one player damages another player who is in a minigame, the
|
||||||
|
* attacker is recorded against the victim with a timestamp. On death, {@link MinigameStatsDeathSystem}
|
||||||
|
* credits an assist to each recent damager other than the killer (see
|
||||||
|
* {@link MinigameStatsService#drainAssisters}). Runs in the inspect phase so it observes the final,
|
||||||
|
* post-mitigation damage amount.
|
||||||
|
*/
|
||||||
|
public final class MinigameStatsDamageSystem extends DamageEventSystem {
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private static final Query<EntityStore> QUERY = PlayerRef.getComponentType();
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Query<EntityStore> getQuery() {
|
||||||
|
return QUERY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public SystemGroup<EntityStore> getGroup() {
|
||||||
|
return DamageModule.get().getInspectDamageGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(
|
||||||
|
int index,
|
||||||
|
@Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
|
@Nonnull Damage damage
|
||||||
|
) {
|
||||||
|
if (damage.getAmount() <= 0.0F || !(damage.getSource() instanceof Damage.EntitySource source)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ref<EntityStore> attackerRef = source.getRef();
|
||||||
|
if (!attackerRef.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PlayerRef attacker = commandBuffer.getComponent(attackerRef, PlayerRef.getComponentType());
|
||||||
|
if (attacker == null) {
|
||||||
|
return; // environmental / NPC damage; not an assist source
|
||||||
|
}
|
||||||
|
PlayerRef victim = archetypeChunk.getComponent(index, PlayerRef.getComponentType());
|
||||||
|
if (victim == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String victimId = victim.getUuid().toString();
|
||||||
|
String attackerId = attacker.getUuid().toString();
|
||||||
|
if (victimId.equals(attackerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameServices services = MinigameCorePlugin.getServices();
|
||||||
|
if (services == null || services.runtime().runtimeForPlayer(victimId).isEmpty()) {
|
||||||
|
return; // victim not in a minigame
|
||||||
|
}
|
||||||
|
services.stats().recordDamage(victimId, attackerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Archetype;
|
||||||
|
import com.hypixel.hytale.component.CommandBuffer;
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.component.query.Query;
|
||||||
|
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.Damage;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.DeathComponent;
|
||||||
|
import com.hypixel.hytale.server.core.modules.entity.damage.DeathSystems;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records KDA into the live stats session on every death. This is the source of truth for
|
||||||
|
* kills/deaths/assists, independent of whether a scoring volume was involved (the existing
|
||||||
|
* {@code MinigameKillScoreSystem} only fires when a kill-score volume matches). A death is only
|
||||||
|
* recorded when the victim is a player currently in a minigame runtime; a kill is credited when the
|
||||||
|
* killer is a different player in that same runtime.
|
||||||
|
*/
|
||||||
|
public final class MinigameStatsDeathSystem extends DeathSystems.OnDeathSystem {
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private static final Query<EntityStore> QUERY =
|
||||||
|
Archetype.of(DeathComponent.getComponentType(), TransformComponent.getComponentType());
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Query<EntityStore> getQuery() {
|
||||||
|
return QUERY;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onComponentAdded(
|
||||||
|
@Nonnull Ref<EntityStore> victimRef,
|
||||||
|
@Nonnull DeathComponent component,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nonnull CommandBuffer<EntityStore> commandBuffer
|
||||||
|
) {
|
||||||
|
String victimId = playerId(store, victimRef);
|
||||||
|
if (victimId == null || victimId.isBlank()) {
|
||||||
|
return; // non-player death (NPC/mob); not part of player KDA
|
||||||
|
}
|
||||||
|
|
||||||
|
MinigameServices services = MinigameCorePlugin.getServices();
|
||||||
|
if (services == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameRuntime runtime = services.runtime().runtimeForPlayer(victimId).orElse(null);
|
||||||
|
if (runtime == null) {
|
||||||
|
return; // victim not in a minigame
|
||||||
|
}
|
||||||
|
|
||||||
|
services.stats().recordDeath(runtime.runtimeId(), victimId);
|
||||||
|
|
||||||
|
String killerId = attackerPlayerId(store, component);
|
||||||
|
if (killerId != null && !killerId.isBlank() && !killerId.equals(victimId)
|
||||||
|
&& runtime.players().containsKey(killerId)) {
|
||||||
|
services.stats().recordKill(runtime.runtimeId(), killerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit an assist to every other recent damager who is still in this game.
|
||||||
|
for (String assisterId : services.stats().drainAssisters(victimId, killerId)) {
|
||||||
|
if (runtime.players().containsKey(assisterId)) {
|
||||||
|
services.stats().recordAssist(runtime.runtimeId(), assisterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static String attackerPlayerId(@Nonnull Store<EntityStore> store, @Nonnull DeathComponent component) {
|
||||||
|
Damage deathInfo = component.getDeathInfo();
|
||||||
|
if (deathInfo == null || !(deathInfo.getSource() instanceof Damage.EntitySource source)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Ref<EntityStore> attackerRef = source.getRef();
|
||||||
|
if (!attackerRef.isValid()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return playerId(store, attackerRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static String playerId(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||||
|
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
|
if (player != null) {
|
||||||
|
return player.getUuid().toString();
|
||||||
|
}
|
||||||
|
UUIDComponent uuid = store.getComponent(ref, UUIDComponent.getComponentType());
|
||||||
|
return uuid != null ? uuid.getUuid().toString() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameEvent;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridges the {@code MinigameEvent} bus to the stats service. Registered globally so it observes
|
||||||
|
* every minigame's lifecycle. All callbacks run synchronously on the dispatching world thread,
|
||||||
|
* which for {@code on_game_end} is guaranteed to be before the runtime state is cleared.
|
||||||
|
*/
|
||||||
|
public final class MinigameStatsListener {
|
||||||
|
|
||||||
|
private final MinigameStatsService stats;
|
||||||
|
private final MinigameRuntimeService runtime;
|
||||||
|
|
||||||
|
public MinigameStatsListener(@Nonnull MinigameStatsService stats, @Nonnull MinigameRuntimeService runtime) {
|
||||||
|
this.stats = stats;
|
||||||
|
this.runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onMinigameEvent(@Nonnull MinigameEvent event) {
|
||||||
|
Map<String, Object> payload = event.payload();
|
||||||
|
String runtimeId = string(payload.get("runtime_id"));
|
||||||
|
if (runtimeId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (event.eventId()) {
|
||||||
|
case "on_game_start" -> withRuntime(runtimeId, stats::beginGame);
|
||||||
|
case "on_round_end" -> withRuntime(runtimeId, rt -> stats.recordRoundPlacement(rt, intValue(payload.get("round"))));
|
||||||
|
case "on_game_end" -> withRuntime(runtimeId, rt -> stats.finishGame(rt, string(payload.get("reason"))));
|
||||||
|
case "on_game_cancelled" -> stats.abortGame(runtimeId);
|
||||||
|
default -> { /* not a stats-relevant event */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void withRuntime(String runtimeId, java.util.function.Consumer<MinigameRuntime> action) {
|
||||||
|
runtime.runtimeById(runtimeId).ifPresent(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(Object value) {
|
||||||
|
return value instanceof String s && !s.isBlank() ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intValue(Object value) {
|
||||||
|
return value instanceof Number n ? n.intValue() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import com.hypixel.hytale.server.core.HytaleServer;
|
||||||
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
|
import net.kewwbec.minigames.model.TeamState;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.runtime.Rankings;
|
||||||
|
import net.kewwbec.minigames.stats.db.GameRecord;
|
||||||
|
import net.kewwbec.minigames.stats.db.MinigameStatsRepository;
|
||||||
|
import net.kewwbec.minigames.stats.db.ParticipantRecord;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.UnaryOperator;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
import static net.kewwbec.minigames.model.Enums.WinCondition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks everything that happens during a running minigame and persists it at game end. All stats
|
||||||
|
* are open-ended named counters (see {@link StatKeys}); recording a brand-new stat anywhere in the
|
||||||
|
* codebase is a single {@link #incrementStat} / {@link #setPlayerAttribute} call with no schema or
|
||||||
|
* model change. A known core subset is promoted to typed columns at persist time; everything else
|
||||||
|
* is written to {@code extras_json} automatically.
|
||||||
|
*
|
||||||
|
* <p>Live mutation happens on the runtime's home-world thread (same thread the lifecycle hooks and
|
||||||
|
* death system run on), so the per-runtime maps need no locking beyond the concurrent session map.
|
||||||
|
* The database write is handed off to {@link HytaleServer#SCHEDULED_EXECUTOR} so the game thread
|
||||||
|
* never blocks on I/O.
|
||||||
|
*/
|
||||||
|
public final class MinigameStatsService {
|
||||||
|
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
private static final Gson GSON = new Gson();
|
||||||
|
|
||||||
|
/** A damager is credited with an assist if their last hit landed within this window before the death. */
|
||||||
|
private static final long ASSIST_WINDOW_MS = 10_000L;
|
||||||
|
|
||||||
|
private final Map<String, GameStatsSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
/** victimId -> (attackerId -> last-hit epoch millis). Populated by the damage system, drained on death. */
|
||||||
|
private final Map<String, Map<String, Long>> recentDamage = new ConcurrentHashMap<>();
|
||||||
|
@Nullable private final MinigameStatsRepository repository;
|
||||||
|
private final UnaryOperator<String> usernameLookup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param repository persistence target; if {@code null} (e.g. DB unavailable) stats are still
|
||||||
|
* tracked in memory but never written.
|
||||||
|
* @param usernameLookup resolves a player id to a display name (e.g. {@code adapters::getDisplayName}).
|
||||||
|
*/
|
||||||
|
public MinigameStatsService(@Nullable MinigameStatsRepository repository, @Nonnull UnaryOperator<String> usernameLookup) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.usernameLookup = usernameLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- lifecycle -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Starts tracking a runtime. Safe to call more than once; the first call wins. */
|
||||||
|
public void beginGame(@Nonnull MinigameRuntime runtime) {
|
||||||
|
sessions.computeIfAbsent(runtime.runtimeId(), id -> new GameStatsSession(
|
||||||
|
UUID.randomUUID().toString(),
|
||||||
|
runtime.runtimeId(),
|
||||||
|
runtime.minigameId(),
|
||||||
|
runtime.mapId(),
|
||||||
|
runtime.variantId(),
|
||||||
|
Instant.now()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops a session without persisting (cancelled pregames, etc.). */
|
||||||
|
public void abortGame(@Nonnull String runtimeId) {
|
||||||
|
sessions.remove(runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public GameStatsSession session(@Nonnull String runtimeId) {
|
||||||
|
return sessions.get(runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public List<MinigameStatsRepository.BreakdownRow> topPlayers(
|
||||||
|
@Nonnull String minigameId,
|
||||||
|
@Nullable String mapId,
|
||||||
|
@Nullable String variantId,
|
||||||
|
@Nullable String teamId,
|
||||||
|
@Nonnull String orderColumn,
|
||||||
|
int limit
|
||||||
|
) {
|
||||||
|
if (repository == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return repository.topPlayers(minigameId, mapId, variantId, teamId, orderColumn, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public List<String> distinctDimensionValues(@Nonnull String minigameId, @Nonnull String dimension) {
|
||||||
|
if (repository == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return repository.distinctDimensionValues(minigameId, dimension);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- recording (open-ended; add new stats with no schema change) -----------------------------
|
||||||
|
|
||||||
|
public void incrementStat(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, long amount) {
|
||||||
|
GameStatsSession session = sessions.get(runtimeId);
|
||||||
|
if (session != null) {
|
||||||
|
session.player(playerId).increment(key, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlayerAttribute(@Nonnull String runtimeId, @Nonnull String playerId, @Nonnull String key, @Nonnull Object value) {
|
||||||
|
GameStatsSession session = sessions.get(runtimeId);
|
||||||
|
if (session != null) {
|
||||||
|
session.player(playerId).putAttribute(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordKill(@Nonnull String runtimeId, @Nonnull String playerId) {
|
||||||
|
incrementStat(runtimeId, playerId, StatKeys.KILLS, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordDeath(@Nonnull String runtimeId, @Nonnull String playerId) {
|
||||||
|
incrementStat(runtimeId, playerId, StatKeys.DEATHS, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordAssist(@Nonnull String runtimeId, @Nonnull String playerId) {
|
||||||
|
incrementStat(runtimeId, playerId, StatKeys.ASSISTS, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records that {@code attackerId} damaged {@code victimId} (assist heuristic input). */
|
||||||
|
public void recordDamage(@Nonnull String victimId, @Nonnull String attackerId) {
|
||||||
|
if (victimId.equals(attackerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recentDamage.computeIfAbsent(victimId, k -> new ConcurrentHashMap<>())
|
||||||
|
.put(attackerId, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the attackers who damaged {@code victimId} within the assist window, excluding the
|
||||||
|
* killer, and clears the victim's recorded damage. Called by the death system to award assists.
|
||||||
|
*/
|
||||||
|
@Nonnull
|
||||||
|
public List<String> drainAssisters(@Nonnull String victimId, @Nullable String killerId) {
|
||||||
|
Map<String, Long> hits = recentDamage.remove(victimId);
|
||||||
|
if (hits == null || hits.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
long cutoff = System.currentTimeMillis() - ASSIST_WINDOW_MS;
|
||||||
|
List<String> assisters = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, Long> e : hits.entrySet()) {
|
||||||
|
if (e.getValue() >= cutoff && !e.getKey().equals(killerId)) {
|
||||||
|
assisters.add(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return assisters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshots the current ranking as each player's placement for {@code roundNumber}. */
|
||||||
|
public void recordRoundPlacement(@Nonnull MinigameRuntime runtime, int roundNumber) {
|
||||||
|
GameStatsSession session = sessions.get(runtime.runtimeId());
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.roundCount(Math.max(session.roundCount(), roundNumber));
|
||||||
|
List<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||||
|
for (int i = 0; i < ranked.size(); i++) {
|
||||||
|
String playerId = ranked.get(i);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Integer> byRound = (Map<String, Integer>) session.player(playerId)
|
||||||
|
.attributes().computeIfAbsent(StatKeys.ATTR_ROUND_PLACEMENTS, k -> new LinkedHashMap<String, Integer>());
|
||||||
|
byRound.put(Integer.toString(roundNumber), i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- finish ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizes scores/placement/win from the live runtime, builds immutable records, and hands them
|
||||||
|
* to the database off-thread. Must be called while the runtime state is still intact (i.e. during
|
||||||
|
* the {@code on_game_end} dispatch, before cleanup).
|
||||||
|
*/
|
||||||
|
public void finishGame(@Nonnull MinigameRuntime runtime, @Nullable String reason) {
|
||||||
|
GameStatsSession session = sessions.remove(runtime.runtimeId());
|
||||||
|
if (session == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runtime.players().keySet().forEach(recentDamage::remove);
|
||||||
|
Instant endedAt = Instant.now();
|
||||||
|
|
||||||
|
List<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||||
|
Map<String, Integer> placement = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i < ranked.size(); i++) {
|
||||||
|
placement.put(ranked.get(i), i + 1);
|
||||||
|
}
|
||||||
|
String winnerPlayerId = ranked.isEmpty() ? null : ranked.get(0);
|
||||||
|
String winnerTeamId = resolveWinningTeam(runtime);
|
||||||
|
|
||||||
|
List<ParticipantRecord> participants = new ArrayList<>();
|
||||||
|
for (PlayerMinigameState state : runtime.players().values()) {
|
||||||
|
if (state.status() == PlayerStatus.SPECTATOR) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String playerId = state.playerId();
|
||||||
|
PlayerStatAccumulator acc = session.player(playerId);
|
||||||
|
acc.teamId(state.teamId());
|
||||||
|
|
||||||
|
Integer place = placement.get(playerId);
|
||||||
|
boolean win = winnerTeamId != null
|
||||||
|
? winnerTeamId.equals(state.teamId())
|
||||||
|
: (place != null && place == 1);
|
||||||
|
long playtimeMs = Math.max(0, endedAt.toEpochMilli() - state.joinedAt().toEpochMilli());
|
||||||
|
|
||||||
|
// Promote authoritative finals into the counter bag so they flow through one code path.
|
||||||
|
acc.set(StatKeys.SCORE, state.score());
|
||||||
|
acc.set(StatKeys.WIN, win ? 1 : 0);
|
||||||
|
acc.set(StatKeys.PLAYTIME_MS, playtimeMs);
|
||||||
|
if (place != null) {
|
||||||
|
acc.set(StatKeys.PLACEMENT, place);
|
||||||
|
}
|
||||||
|
|
||||||
|
participants.add(new ParticipantRecord(
|
||||||
|
session.gameId(),
|
||||||
|
playerId,
|
||||||
|
safeUsername(playerId),
|
||||||
|
session.minigameId(),
|
||||||
|
state.teamId(),
|
||||||
|
place,
|
||||||
|
win,
|
||||||
|
acc.get(StatKeys.KILLS),
|
||||||
|
acc.get(StatKeys.DEATHS),
|
||||||
|
acc.get(StatKeys.ASSISTS),
|
||||||
|
acc.get(StatKeys.SCORE),
|
||||||
|
playtimeMs,
|
||||||
|
extrasJson(acc)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason != null) {
|
||||||
|
session.attributes().put("end_reason", reason);
|
||||||
|
}
|
||||||
|
GameRecord game = new GameRecord(
|
||||||
|
session.gameId(),
|
||||||
|
session.minigameId(),
|
||||||
|
session.mapId(),
|
||||||
|
session.variantId(),
|
||||||
|
session.roundCount(),
|
||||||
|
reason,
|
||||||
|
winnerPlayerId,
|
||||||
|
winnerTeamId,
|
||||||
|
session.startedAt(),
|
||||||
|
endedAt,
|
||||||
|
session.attributes().isEmpty() ? null : GSON.toJson(session.attributes())
|
||||||
|
);
|
||||||
|
|
||||||
|
persistAsync(game, participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistAsync(GameRecord game, List<ParticipantRecord> participants) {
|
||||||
|
if (repository == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HytaleServer.SCHEDULED_EXECUTOR.execute(() -> {
|
||||||
|
try {
|
||||||
|
repository.persistGame(game, participants);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
((HytaleLogger.Api) LOGGER.at(Level.SEVERE).withCause(e))
|
||||||
|
.log("Failed to persist minigame stats for game %s (%s)", game.id(), game.minigameId());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Serializes everything that is not a promoted core column into a JSON blob (null when empty). */
|
||||||
|
@Nullable
|
||||||
|
private static String extrasJson(PlayerStatAccumulator acc) {
|
||||||
|
Map<String, Object> extras = new LinkedHashMap<>();
|
||||||
|
acc.counters().forEach((key, value) -> {
|
||||||
|
if (!StatKeys.CORE.contains(key)) {
|
||||||
|
extras.put(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
extras.putAll(acc.attributes());
|
||||||
|
return extras.isEmpty() ? null : GSON.toJson(extras);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static String resolveWinningTeam(MinigameRuntime runtime) {
|
||||||
|
Map<String, TeamState> teams = runtime.teams();
|
||||||
|
if (teams.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
boolean lowestWins = switch (runtime.definition().getWinCondition()) {
|
||||||
|
case LOWEST_SCORE, LOWEST_TIME -> true;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
TeamState best = null;
|
||||||
|
for (TeamState team : teams.values()) {
|
||||||
|
if (best == null
|
||||||
|
|| (lowestWins ? team.score() < best.score() : team.score() > best.score())) {
|
||||||
|
best = team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best == null ? null : best.teamId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeUsername(String playerId) {
|
||||||
|
try {
|
||||||
|
return usernameLookup.apply(playerId);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable per-player tally for a single game. Holds two open-ended bags:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code counters} - additive numeric stats (kills, deaths, score, and any future counter).</li>
|
||||||
|
* <li>{@code attributes} - arbitrary structured values (e.g. per-round placement lists).</li>
|
||||||
|
* </ul>
|
||||||
|
* Nothing here is hard-coded per stat, so new stats need no changes to this class.
|
||||||
|
*/
|
||||||
|
public final class PlayerStatAccumulator {
|
||||||
|
private final String playerId;
|
||||||
|
@Nullable private String teamId;
|
||||||
|
private final Map<String, Long> counters = new LinkedHashMap<>();
|
||||||
|
private final Map<String, Object> attributes = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
public PlayerStatAccumulator(String playerId) {
|
||||||
|
this.playerId = playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String playerId() {
|
||||||
|
return playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public String teamId() {
|
||||||
|
return teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void teamId(@Nullable String teamId) {
|
||||||
|
this.teamId = teamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void increment(String key, long amount) {
|
||||||
|
counters.merge(key, amount, Long::sum);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void set(String key, long value) {
|
||||||
|
counters.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public long get(String key) {
|
||||||
|
return counters.getOrDefault(key, 0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Long> counters() {
|
||||||
|
return counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void putAttribute(String key, Object value) {
|
||||||
|
attributes.put(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> attributes() {
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package net.kewwbec.minigames.stats;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Well-known stat keys. Every stat the service tracks is just a named counter, so adding a new
|
||||||
|
* one is as simple as picking a key and calling {@link MinigameStatsService#incrementStat}. The
|
||||||
|
* keys listed in {@link #CORE} are the only ones promoted to typed database columns; any other
|
||||||
|
* key is persisted automatically into the participant's {@code extras_json} blob with no schema
|
||||||
|
* change required.
|
||||||
|
*/
|
||||||
|
public final class StatKeys {
|
||||||
|
private StatKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String KILLS = "kills";
|
||||||
|
public static final String DEATHS = "deaths";
|
||||||
|
public static final String ASSISTS = "assists";
|
||||||
|
public static final String SCORE = "score";
|
||||||
|
/** Final finishing position, 1 = first. Filled in at game end from {@code Rankings}. */
|
||||||
|
public static final String PLACEMENT = "placement";
|
||||||
|
/** 1 if the player (or their team) won, else 0. Filled in at game end. */
|
||||||
|
public static final String WIN = "win";
|
||||||
|
/** Milliseconds the player spent in the game (join -> end). Filled in at game end. */
|
||||||
|
public static final String PLAYTIME_MS = "playtime_ms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats promoted to dedicated, queryable columns. Everything else lives in {@code extras_json}.
|
||||||
|
* To promote a future stat to a column, add its key here and add the matching column + migration.
|
||||||
|
*/
|
||||||
|
public static final Set<String> CORE = Set.of(KILLS, DEATHS, ASSISTS, SCORE, PLACEMENT, WIN, PLAYTIME_MS);
|
||||||
|
|
||||||
|
/** Non-numeric per-player attribute: ordered list of per-round placements. */
|
||||||
|
public static final String ATTR_ROUND_PLACEMENTS = "round_placements";
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package net.kewwbec.minigames.stats.db;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** Immutable persistence view of one finished game. {@code extrasJson} carries game-wide extras. */
|
||||||
|
public record GameRecord(
|
||||||
|
String id,
|
||||||
|
String minigameId,
|
||||||
|
String mapId,
|
||||||
|
String variantId,
|
||||||
|
int roundCount,
|
||||||
|
@Nullable String endReason,
|
||||||
|
@Nullable String winnerPlayerId,
|
||||||
|
@Nullable String winnerTeamId,
|
||||||
|
Instant startedAt,
|
||||||
|
Instant endedAt,
|
||||||
|
@Nullable String extrasJson
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package net.kewwbec.minigames.stats.db;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Types;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists games, per-game participants, and rolled-up lifetime aggregates. All SQL lives here so
|
||||||
|
* a backend pivot only touches this class plus the {@link StatsDatabase} implementation. The
|
||||||
|
* {@code GLOBAL} aggregate key gives a fast cross-game leaderboard alongside the per-minigame rows.
|
||||||
|
*/
|
||||||
|
public final class MinigameStatsRepository {
|
||||||
|
|
||||||
|
/** Synthetic minigame id used for the cross-game (global) aggregate row. */
|
||||||
|
public static final String GLOBAL = "*";
|
||||||
|
|
||||||
|
private final StatsDatabase db;
|
||||||
|
|
||||||
|
public MinigameStatsRepository(@Nonnull StatsDatabase db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the game header, every participant, and both per-minigame and global aggregates in one transaction. */
|
||||||
|
public void persistGame(@Nonnull GameRecord game, @Nonnull List<ParticipantRecord> participants) {
|
||||||
|
db.execute(c -> {
|
||||||
|
boolean previousAutoCommit = c.getAutoCommit();
|
||||||
|
c.setAutoCommit(false);
|
||||||
|
try {
|
||||||
|
saveGame(c, game);
|
||||||
|
for (ParticipantRecord p : participants) {
|
||||||
|
saveParticipant(c, p);
|
||||||
|
bumpAggregate(c, p, p.minigameId());
|
||||||
|
bumpAggregate(c, p, GLOBAL);
|
||||||
|
}
|
||||||
|
c.commit();
|
||||||
|
} catch (SQLException | RuntimeException e) {
|
||||||
|
c.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
c.setAutoCommit(previousAutoCommit);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void saveGame(Connection c, GameRecord g) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO minigame_games
|
||||||
|
(id, minigame_id, map_id, variant_id, round_count, end_reason, winner_player_id, winner_team_id, started_at, ended_at, extras_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
round_count = excluded.round_count,
|
||||||
|
end_reason = excluded.end_reason,
|
||||||
|
winner_player_id = excluded.winner_player_id,
|
||||||
|
winner_team_id = excluded.winner_team_id,
|
||||||
|
ended_at = excluded.ended_at,
|
||||||
|
extras_json = excluded.extras_json
|
||||||
|
""";
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, g.id());
|
||||||
|
p.setString(2, g.minigameId());
|
||||||
|
p.setString(3, g.mapId());
|
||||||
|
p.setString(4, g.variantId());
|
||||||
|
p.setInt(5, g.roundCount());
|
||||||
|
p.setString(6, g.endReason());
|
||||||
|
p.setString(7, g.winnerPlayerId());
|
||||||
|
p.setString(8, g.winnerTeamId());
|
||||||
|
p.setString(9, g.startedAt().toString());
|
||||||
|
p.setString(10, g.endedAt().toString());
|
||||||
|
p.setString(11, g.extrasJson());
|
||||||
|
p.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void saveParticipant(Connection c, ParticipantRecord r) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO minigame_game_participants
|
||||||
|
(game_id, player_uuid, player_username, minigame_id, team_id, placement, win, kills, deaths, assists, score, playtime_ms, extras_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(game_id, player_uuid) DO UPDATE SET
|
||||||
|
player_username = excluded.player_username,
|
||||||
|
team_id = excluded.team_id,
|
||||||
|
placement = excluded.placement,
|
||||||
|
win = excluded.win,
|
||||||
|
kills = excluded.kills,
|
||||||
|
deaths = excluded.deaths,
|
||||||
|
assists = excluded.assists,
|
||||||
|
score = excluded.score,
|
||||||
|
playtime_ms = excluded.playtime_ms,
|
||||||
|
extras_json = excluded.extras_json
|
||||||
|
""";
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, r.gameId());
|
||||||
|
p.setString(2, r.playerUuid());
|
||||||
|
p.setString(3, r.playerUsername());
|
||||||
|
p.setString(4, r.minigameId());
|
||||||
|
p.setString(5, r.teamId());
|
||||||
|
if (r.placement() == null) {
|
||||||
|
p.setNull(6, Types.INTEGER);
|
||||||
|
} else {
|
||||||
|
p.setInt(6, r.placement());
|
||||||
|
}
|
||||||
|
p.setInt(7, r.win() ? 1 : 0);
|
||||||
|
p.setLong(8, r.kills());
|
||||||
|
p.setLong(9, r.deaths());
|
||||||
|
p.setLong(10, r.assists());
|
||||||
|
p.setLong(11, r.score());
|
||||||
|
p.setLong(12, r.playtimeMs());
|
||||||
|
p.setString(13, r.extrasJson());
|
||||||
|
p.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Increments the lifetime aggregate row for the given (player, minigameId) bucket. */
|
||||||
|
private static void bumpAggregate(Connection c, ParticipantRecord r, String minigameId) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO minigame_player_stats
|
||||||
|
(player_uuid, minigame_id, player_username, games_played, wins, losses, kills, deaths, assists, score_total, best_placement, playtime_ms, last_played_at)
|
||||||
|
VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(player_uuid, minigame_id) DO UPDATE SET
|
||||||
|
player_username = excluded.player_username,
|
||||||
|
games_played = games_played + 1,
|
||||||
|
wins = wins + excluded.wins,
|
||||||
|
losses = losses + excluded.losses,
|
||||||
|
kills = kills + excluded.kills,
|
||||||
|
deaths = deaths + excluded.deaths,
|
||||||
|
assists = assists + excluded.assists,
|
||||||
|
score_total = score_total + excluded.score_total,
|
||||||
|
best_placement = CASE
|
||||||
|
WHEN excluded.best_placement IS NULL THEN best_placement
|
||||||
|
WHEN best_placement IS NULL THEN excluded.best_placement
|
||||||
|
WHEN excluded.best_placement < best_placement THEN excluded.best_placement
|
||||||
|
ELSE best_placement END,
|
||||||
|
playtime_ms = playtime_ms + excluded.playtime_ms,
|
||||||
|
last_played_at = excluded.last_played_at
|
||||||
|
""";
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, r.playerUuid());
|
||||||
|
p.setString(2, minigameId);
|
||||||
|
p.setString(3, r.playerUsername());
|
||||||
|
p.setInt(4, r.win() ? 1 : 0);
|
||||||
|
p.setInt(5, r.win() ? 0 : 1);
|
||||||
|
p.setLong(6, r.kills());
|
||||||
|
p.setLong(7, r.deaths());
|
||||||
|
p.setLong(8, r.assists());
|
||||||
|
p.setLong(9, r.score());
|
||||||
|
if (r.placement() == null) {
|
||||||
|
p.setNull(10, Types.INTEGER);
|
||||||
|
} else {
|
||||||
|
p.setInt(10, r.placement());
|
||||||
|
}
|
||||||
|
p.setLong(11, r.playtimeMs());
|
||||||
|
p.setString(12, java.time.Instant.now().toString());
|
||||||
|
p.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top players for a minigame (use {@link #GLOBAL} for the cross-game board) ordered by a core column. */
|
||||||
|
@Nonnull
|
||||||
|
public List<AggregateRow> topPlayers(@Nonnull String minigameId, @Nonnull String orderColumn, int limit) {
|
||||||
|
String column = sanitizeOrderColumn(orderColumn);
|
||||||
|
String sql = "SELECT player_uuid, minigame_id, player_username, games_played, wins, losses, kills, deaths, "
|
||||||
|
+ "assists, score_total, best_placement, playtime_ms, last_played_at FROM minigame_player_stats "
|
||||||
|
+ "WHERE minigame_id = ? ORDER BY " + column + " DESC LIMIT ?";
|
||||||
|
return db.withConnection(c -> {
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, minigameId);
|
||||||
|
p.setInt(2, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = p.executeQuery()) {
|
||||||
|
List<AggregateRow> rows = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
rows.add(readAggregate(rs));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top players for a minigame, optionally narrowed by finished-game map, variant, and team. */
|
||||||
|
@Nonnull
|
||||||
|
public List<BreakdownRow> topPlayers(
|
||||||
|
@Nonnull String minigameId,
|
||||||
|
@Nullable String mapId,
|
||||||
|
@Nullable String variantId,
|
||||||
|
@Nullable String teamId,
|
||||||
|
@Nonnull String orderColumn,
|
||||||
|
int limit
|
||||||
|
) {
|
||||||
|
String column = sanitizeBreakdownOrderColumn(orderColumn);
|
||||||
|
String sql = """
|
||||||
|
SELECT
|
||||||
|
p.player_uuid,
|
||||||
|
COALESCE(MAX(p.player_username), p.player_uuid) AS player_username,
|
||||||
|
COUNT(*) AS games_played,
|
||||||
|
SUM(p.win) AS wins,
|
||||||
|
COUNT(*) - SUM(p.win) AS losses,
|
||||||
|
SUM(p.kills) AS kills,
|
||||||
|
SUM(p.deaths) AS deaths,
|
||||||
|
SUM(p.assists) AS assists,
|
||||||
|
SUM(p.score) AS score_total,
|
||||||
|
MIN(p.placement) AS best_placement,
|
||||||
|
SUM(p.playtime_ms) AS playtime_ms,
|
||||||
|
MAX(g.ended_at) AS last_played_at
|
||||||
|
FROM minigame_game_participants p
|
||||||
|
JOIN minigame_games g ON g.id = p.game_id
|
||||||
|
WHERE p.minigame_id = ?
|
||||||
|
AND (? = '' OR COALESCE(g.map_id, '') = ?)
|
||||||
|
AND (? = '' OR COALESCE(g.variant_id, '') = ?)
|
||||||
|
AND (? = '' OR COALESCE(p.team_id, '') = ?)
|
||||||
|
GROUP BY p.player_uuid
|
||||||
|
ORDER BY %s DESC
|
||||||
|
LIMIT ?
|
||||||
|
""".formatted(column);
|
||||||
|
return db.withConnection(c -> {
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, minigameId);
|
||||||
|
bindBlankFilter(p, 2, mapId);
|
||||||
|
bindBlankFilter(p, 4, variantId);
|
||||||
|
bindBlankFilter(p, 6, teamId);
|
||||||
|
p.setInt(8, Math.max(1, limit));
|
||||||
|
try (ResultSet rs = p.executeQuery()) {
|
||||||
|
List<BreakdownRow> rows = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
rows.add(readBreakdown(rs));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distinct values observed in persisted games for one drilldown dimension. */
|
||||||
|
@Nonnull
|
||||||
|
public List<String> distinctDimensionValues(@Nonnull String minigameId, @Nonnull String dimension) {
|
||||||
|
String sql = switch (dimension) {
|
||||||
|
case "map" -> """
|
||||||
|
SELECT DISTINCT COALESCE(map_id, '') AS value
|
||||||
|
FROM minigame_games
|
||||||
|
WHERE minigame_id = ? AND COALESCE(map_id, '') <> ''
|
||||||
|
ORDER BY value
|
||||||
|
""";
|
||||||
|
case "variant" -> """
|
||||||
|
SELECT DISTINCT COALESCE(variant_id, '') AS value
|
||||||
|
FROM minigame_games
|
||||||
|
WHERE minigame_id = ? AND COALESCE(variant_id, '') <> ''
|
||||||
|
ORDER BY value
|
||||||
|
""";
|
||||||
|
case "team" -> """
|
||||||
|
SELECT DISTINCT COALESCE(team_id, '') AS value
|
||||||
|
FROM minigame_game_participants
|
||||||
|
WHERE minigame_id = ? AND COALESCE(team_id, '') <> ''
|
||||||
|
ORDER BY value
|
||||||
|
""";
|
||||||
|
default -> throw new IllegalArgumentException("Unsupported stats dimension: " + dimension);
|
||||||
|
};
|
||||||
|
return db.withConnection(c -> {
|
||||||
|
try (PreparedStatement p = c.prepareStatement(sql)) {
|
||||||
|
p.setString(1, minigameId);
|
||||||
|
try (ResultSet rs = p.executeQuery()) {
|
||||||
|
List<String> values = new ArrayList<>();
|
||||||
|
while (rs.next()) {
|
||||||
|
values.add(rs.getString("value"));
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AggregateRow readAggregate(ResultSet rs) throws SQLException {
|
||||||
|
int bestPlacement = rs.getInt("best_placement");
|
||||||
|
return new AggregateRow(
|
||||||
|
rs.getString("player_uuid"),
|
||||||
|
rs.getString("minigame_id"),
|
||||||
|
rs.getString("player_username"),
|
||||||
|
rs.getLong("games_played"),
|
||||||
|
rs.getLong("wins"),
|
||||||
|
rs.getLong("losses"),
|
||||||
|
rs.getLong("kills"),
|
||||||
|
rs.getLong("deaths"),
|
||||||
|
rs.getLong("assists"),
|
||||||
|
rs.getLong("score_total"),
|
||||||
|
rs.wasNull() ? null : bestPlacement,
|
||||||
|
rs.getLong("playtime_ms"),
|
||||||
|
rs.getString("last_played_at")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BreakdownRow readBreakdown(ResultSet rs) throws SQLException {
|
||||||
|
int bestPlacement = rs.getInt("best_placement");
|
||||||
|
return new BreakdownRow(
|
||||||
|
rs.getString("player_uuid"),
|
||||||
|
rs.getString("player_username"),
|
||||||
|
rs.getLong("games_played"),
|
||||||
|
rs.getLong("wins"),
|
||||||
|
rs.getLong("losses"),
|
||||||
|
rs.getLong("kills"),
|
||||||
|
rs.getLong("deaths"),
|
||||||
|
rs.getLong("assists"),
|
||||||
|
rs.getLong("score_total"),
|
||||||
|
rs.wasNull() ? null : bestPlacement,
|
||||||
|
rs.getLong("playtime_ms"),
|
||||||
|
rs.getString("last_played_at")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void bindBlankFilter(PreparedStatement p, int firstIndex, @Nullable String value) throws SQLException {
|
||||||
|
String normalized = value == null ? "" : value.trim();
|
||||||
|
p.setString(firstIndex, normalized);
|
||||||
|
p.setString(firstIndex + 1, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whitelist guard: only the fixed aggregate columns may be sorted on (avoids SQL injection). */
|
||||||
|
private static String sanitizeOrderColumn(String requested) {
|
||||||
|
return switch (requested) {
|
||||||
|
case "games_played", "wins", "losses", "kills", "deaths", "assists", "score_total", "playtime_ms" -> requested;
|
||||||
|
default -> "score_total";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeBreakdownOrderColumn(String requested) {
|
||||||
|
return switch (requested) {
|
||||||
|
case "games_played", "wins", "losses", "kills", "deaths", "assists", "score_total", "playtime_ms" -> requested;
|
||||||
|
default -> "score_total";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read view of a lifetime aggregate row. */
|
||||||
|
public record AggregateRow(
|
||||||
|
String playerUuid,
|
||||||
|
String minigameId,
|
||||||
|
@Nullable String playerUsername,
|
||||||
|
long gamesPlayed,
|
||||||
|
long wins,
|
||||||
|
long losses,
|
||||||
|
long kills,
|
||||||
|
long deaths,
|
||||||
|
long assists,
|
||||||
|
long scoreTotal,
|
||||||
|
@Nullable Integer bestPlacement,
|
||||||
|
long playtimeMs,
|
||||||
|
@Nullable String lastPlayedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read view for a filtered player leaderboard assembled from participant history. */
|
||||||
|
public record BreakdownRow(
|
||||||
|
String playerUuid,
|
||||||
|
@Nullable String playerUsername,
|
||||||
|
long gamesPlayed,
|
||||||
|
long wins,
|
||||||
|
long losses,
|
||||||
|
long kills,
|
||||||
|
long deaths,
|
||||||
|
long assists,
|
||||||
|
long scoreTotal,
|
||||||
|
@Nullable Integer bestPlacement,
|
||||||
|
long playtimeMs,
|
||||||
|
@Nullable String lastPlayedAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package net.kewwbec.minigames.stats.db;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable persistence view of one player's result in one game. The core stats are explicit
|
||||||
|
* fields; any other tracked stat is serialized into {@code extrasJson}. {@code win} drives the
|
||||||
|
* win/loss split in the rolled-up aggregate table.
|
||||||
|
*/
|
||||||
|
public record ParticipantRecord(
|
||||||
|
String gameId,
|
||||||
|
String playerUuid,
|
||||||
|
@Nullable String playerUsername,
|
||||||
|
String minigameId,
|
||||||
|
@Nullable String teamId,
|
||||||
|
@Nullable Integer placement,
|
||||||
|
boolean win,
|
||||||
|
long kills,
|
||||||
|
long deaths,
|
||||||
|
long assists,
|
||||||
|
long score,
|
||||||
|
long playtimeMs,
|
||||||
|
@Nullable String extrasJson
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package net.kewwbec.minigames.stats.db;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite-backed {@link StatsDatabase}. Holds a single shared connection (SQLite serializes writes
|
||||||
|
* anyway) and runs all repository work against it under a monitor. Mirrors the proven Duel module
|
||||||
|
* setup. The pivot to MySQL is a matter of supplying a different {@link StatsDatabase} that borrows
|
||||||
|
* from a pool; the repository and service stay unchanged.
|
||||||
|
*
|
||||||
|
* <p>Schema evolution is intentionally trivial: append a {@code CREATE TABLE IF NOT EXISTS} (or a
|
||||||
|
* new {@link #addColumnIfMissing} call) to {@link #migrate()} to add a new record type or column.
|
||||||
|
*/
|
||||||
|
public final class SqliteStatsDatabase implements StatsDatabase {
|
||||||
|
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private final String jdbcUrl;
|
||||||
|
private Connection connection;
|
||||||
|
|
||||||
|
public SqliteStatsDatabase(@Nonnull String jdbcUrl) {
|
||||||
|
this.jdbcUrl = jdbcUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void start() {
|
||||||
|
if (connection != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Class.forName("org.sqlite.JDBC");
|
||||||
|
connection = DriverManager.getConnection(jdbcUrl);
|
||||||
|
connection.setAutoCommit(true);
|
||||||
|
migrate();
|
||||||
|
LOGGER.at(Level.INFO).log("Minigame stats database ready (%s)", jdbcUrl);
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
throw new IllegalStateException("SQLite JDBC driver is missing from the core-minigames jar", e);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new IllegalStateException("Failed to start minigame stats database", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized <T> T withConnection(SqlFunction<T> work) {
|
||||||
|
if (connection == null) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return work.apply(connection);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new IllegalStateException("Minigame stats database operation failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void migrate() throws SQLException {
|
||||||
|
for (String statement : migrations()) {
|
||||||
|
try (Statement sql = connection.createStatement()) {
|
||||||
|
sql.execute(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private void addColumnIfMissing(String table, String column, String definition) throws SQLException {
|
||||||
|
try (Statement sql = connection.createStatement();
|
||||||
|
ResultSet rows = sql.executeQuery("PRAGMA table_info(" + table + ")")) {
|
||||||
|
while (rows.next()) {
|
||||||
|
if (column.equalsIgnoreCase(rows.getString("name"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (Statement sql = connection.createStatement()) {
|
||||||
|
sql.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> migrations() {
|
||||||
|
List<String> sql = new ArrayList<>();
|
||||||
|
// One row per finished game.
|
||||||
|
sql.add("""
|
||||||
|
CREATE TABLE IF NOT EXISTS minigame_games (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
minigame_id TEXT NOT NULL,
|
||||||
|
map_id TEXT,
|
||||||
|
variant_id TEXT,
|
||||||
|
round_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
end_reason TEXT,
|
||||||
|
winner_player_id TEXT,
|
||||||
|
winner_team_id TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT NOT NULL,
|
||||||
|
extras_json TEXT
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
// One row per player per game (full history). Core stats are columns; the rest is extras_json.
|
||||||
|
sql.add("""
|
||||||
|
CREATE TABLE IF NOT EXISTS minigame_game_participants (
|
||||||
|
game_id TEXT NOT NULL,
|
||||||
|
player_uuid TEXT NOT NULL,
|
||||||
|
player_username TEXT,
|
||||||
|
minigame_id TEXT NOT NULL,
|
||||||
|
team_id TEXT,
|
||||||
|
placement INTEGER,
|
||||||
|
win INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deaths INTEGER NOT NULL DEFAULT 0,
|
||||||
|
assists INTEGER NOT NULL DEFAULT 0,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
playtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
extras_json TEXT,
|
||||||
|
PRIMARY KEY (game_id, player_uuid)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
// Rolled-up lifetime totals. minigame_id = '*' holds the cross-game global rollup.
|
||||||
|
sql.add("""
|
||||||
|
CREATE TABLE IF NOT EXISTS minigame_player_stats (
|
||||||
|
player_uuid TEXT NOT NULL,
|
||||||
|
minigame_id TEXT NOT NULL,
|
||||||
|
player_username TEXT,
|
||||||
|
games_played INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
kills INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deaths INTEGER NOT NULL DEFAULT 0,
|
||||||
|
assists INTEGER NOT NULL DEFAULT 0,
|
||||||
|
score_total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
best_placement INTEGER,
|
||||||
|
playtime_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_played_at TEXT,
|
||||||
|
extras_json TEXT,
|
||||||
|
PRIMARY KEY (player_uuid, minigame_id)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void close() {
|
||||||
|
if (connection == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
connection.close();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
LOGGER.at(Level.WARNING).log("Failed to close minigame stats database: %s", e.getMessage());
|
||||||
|
} finally {
|
||||||
|
connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package net.kewwbec.minigames.stats.db;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection lifecycle seam between the stats repository and the underlying database. This is the
|
||||||
|
* single point that changes when pivoting from the local SQLite backend to the network-wide MySQL
|
||||||
|
* pool: the repository never touches a {@link Connection} directly, it asks the database to run
|
||||||
|
* work with one, and each backend manages the connection appropriately (a shared single connection
|
||||||
|
* for SQLite, a pooled borrow-and-return for MySQL).
|
||||||
|
*/
|
||||||
|
public interface StatsDatabase extends AutoCloseable {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface SqlFunction<T> {
|
||||||
|
T apply(Connection connection) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface SqlConsumer {
|
||||||
|
void accept(Connection connection) throws SQLException;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs read/write work with a managed connection and returns its result. */
|
||||||
|
<T> T withConnection(SqlFunction<T> work);
|
||||||
|
|
||||||
|
/** Runs write work with a managed connection. */
|
||||||
|
default void execute(SqlConsumer work) {
|
||||||
|
withConnection(connection -> {
|
||||||
|
work.accept(connection);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ import java.util.Locale;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.FriendlyFireKillScoreMode.LOSES_POINTS;
|
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 {
|
public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
||||||
@Nonnull
|
@Nonnull
|
||||||
@@ -72,6 +73,9 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
|||||||
if (runtime == null) {
|
if (runtime == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (runtime.phase() != ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String victimKind = victimKind(store, victimRef);
|
String victimKind = victimKind(store, victimRef);
|
||||||
if (victimKind.isBlank()) {
|
if (victimKind.isBlank()) {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package net.kewwbec.minigames.system;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.component.system.tick.TickingSystem;
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameLeaderboardUIService;
|
||||||
|
|
||||||
|
public final class MinigameLeaderboardUISystem extends TickingSystem<EntityStore> {
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private final MinigameLeaderboardUIService service;
|
||||||
|
|
||||||
|
public MinigameLeaderboardUISystem(MinigameLeaderboardUIService service) {
|
||||||
|
this.service = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
||||||
|
try {
|
||||||
|
service.openQueued(store);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOGGER.atWarning().log("Leaderboard UI tick failed: %s: %s",
|
||||||
|
e.getClass().getSimpleName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,16 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null);
|
return services.runtime().runtimeForPlayer(player.getUuid().toString()).orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the player behind {@code ref} is a spectator of their current runtime. */
|
||||||
|
private static boolean isSpectator(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||||
|
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
|
if (player == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||||
|
return runtime != null && runtime.spectators().contains(player.getUuid().toString());
|
||||||
|
}
|
||||||
|
|
||||||
/** Cancels player-vs-player damage when either party's minigame disables PvP. */
|
/** Cancels player-vs-player damage when either party's minigame disables PvP. */
|
||||||
public static final class PvpGuard extends DamageEventSystem {
|
public static final class PvpGuard extends DamageEventSystem {
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -75,6 +85,11 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index);
|
Ref<EntityStore> victimRef = archetypeChunk.getReferenceTo(index);
|
||||||
|
// Spectators can neither be hit nor deal damage, regardless of the definition's PvP flag.
|
||||||
|
if (isSpectator(store, victimRef) || isSpectator(store, attackerRef)) {
|
||||||
|
damage.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
MinigameRuntime victimRuntime = runtimeFor(store, victimRef);
|
MinigameRuntime victimRuntime = runtimeFor(store, victimRef);
|
||||||
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
||||||
damage.setCancelled(true);
|
damage.setCancelled(true);
|
||||||
@@ -110,7 +125,12 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
if (event.isCancelled()) {
|
if (event.isCancelled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
|
||||||
|
if (isSpectator(store, ref)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||||
if (runtime != null && !runtime.definition().isAllowBlockBreaking()) {
|
if (runtime != null && !runtime.definition().isAllowBlockBreaking()) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
@@ -140,7 +160,12 @@ public final class MinigameRuleEnforcementSystems {
|
|||||||
if (event.isCancelled()) {
|
if (event.isCancelled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MinigameRuntime runtime = runtimeFor(store, archetypeChunk.getReferenceTo(index));
|
Ref<EntityStore> ref = archetypeChunk.getReferenceTo(index);
|
||||||
|
if (isSpectator(store, ref)) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameRuntime runtime = runtimeFor(store, ref);
|
||||||
if (runtime != null && !runtime.definition().isAllowBlockPlacing()) {
|
if (runtime != null && !runtime.definition().isAllowBlockPlacing()) {
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package net.kewwbec.minigames.ui;
|
||||||
|
|
||||||
|
import au.ellie.hyui.builders.ButtonBuilder;
|
||||||
|
import au.ellie.hyui.builders.ContainerBuilder;
|
||||||
|
import au.ellie.hyui.builders.GroupBuilder;
|
||||||
|
import au.ellie.hyui.builders.HyUIAnchor;
|
||||||
|
import au.ellie.hyui.builders.HyUIPadding;
|
||||||
|
import au.ellie.hyui.builders.HyUIPatchStyle;
|
||||||
|
import au.ellie.hyui.builders.HyUIStyle;
|
||||||
|
import au.ellie.hyui.builders.LabelBuilder;
|
||||||
|
import au.ellie.hyui.builders.PageBuilder;
|
||||||
|
import au.ellie.hyui.builders.UIElementBuilder;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||||
|
import com.hypixel.hytale.component.Ref;
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.stats.db.MinigameStatsRepository;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
public final class MinigameLeaderboardUIService {
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
private static final long TTL_MS = 10_000;
|
||||||
|
private static final int SECTION_INSET = 22;
|
||||||
|
private static final int LIMIT = 10;
|
||||||
|
|
||||||
|
private final MinigameServices services;
|
||||||
|
private final ConcurrentHashMap<UUID, PendingOpen> pendingOpens = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public MinigameLeaderboardUIService(MinigameServices services) {
|
||||||
|
this.services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requestOpen(PlayerRef player, @Nullable String minigameId) {
|
||||||
|
pendingOpens.put(player.getUuid(), new PendingOpen(
|
||||||
|
player.getUuid(),
|
||||||
|
minigameId == null ? "" : minigameId.trim(),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openQueued(Store<EntityStore> store) {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
TriggerVolumesPlugin tvPlugin = TriggerVolumesPlugin.get();
|
||||||
|
TriggerVolumeManager manager = null;
|
||||||
|
if (tvPlugin != null) {
|
||||||
|
manager = store.getResource(tvPlugin.getManagerResourceType());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (PendingOpen pending : pendingOpens.values()) {
|
||||||
|
if (now - pending.createdAtMillis() > TTL_MS) {
|
||||||
|
pendingOpens.remove(pending.uuid(), pending);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.uuid());
|
||||||
|
if (ref == null || !ref.isValid()) continue;
|
||||||
|
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
|
if (player == null) continue;
|
||||||
|
if (pendingOpens.remove(pending.uuid(), pending)) {
|
||||||
|
try {
|
||||||
|
open(player, pending.minigameId(), "", "", "", "score_total", store, manager);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
LOGGER.atWarning().log("Leaderboard UI open failed player=%s: %s: %s",
|
||||||
|
player.getUuid(), e.getClass().getSimpleName(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void open(
|
||||||
|
PlayerRef player,
|
||||||
|
String selectedMinigameId,
|
||||||
|
String mapId,
|
||||||
|
String variantId,
|
||||||
|
String teamId,
|
||||||
|
String sort,
|
||||||
|
Store<EntityStore> store,
|
||||||
|
@Nullable TriggerVolumeManager manager
|
||||||
|
) {
|
||||||
|
List<MinigameDefinition> defs = services.minigames().definitions().stream()
|
||||||
|
.filter(MinigameDefinition::isEnabled)
|
||||||
|
.sorted(java.util.Comparator.comparing(MinigameDefinition::getId))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
String minigameId = selectedMinigameId;
|
||||||
|
if (minigameId.isBlank() && !defs.isEmpty()) {
|
||||||
|
minigameId = defs.get(0).getId();
|
||||||
|
}
|
||||||
|
String finalMinigameId = minigameId;
|
||||||
|
MinigameDefinition selected = defs.stream()
|
||||||
|
.filter(def -> def.getId().equals(finalMinigameId))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
GroupBuilder gameList = surface("MINIGAMES", "Enabled definitions", 288, 474);
|
||||||
|
if (defs.isEmpty()) {
|
||||||
|
gameList.addChild(inset(caption("No enabled minigames are loaded.", 226), 288, 226, 24));
|
||||||
|
} else {
|
||||||
|
for (MinigameDefinition def : defs) {
|
||||||
|
int maps = activeMapIds(manager, def.getId()).size();
|
||||||
|
boolean active = def.getId().equals(minigameId);
|
||||||
|
gameList.addChild(spacer(288, 6));
|
||||||
|
gameList.addChild(inset(
|
||||||
|
ButtonBuilder.secondaryTextButton()
|
||||||
|
.withText((active ? "> " : "") + def.getName() + " | " + maps + " maps")
|
||||||
|
.withTooltipText(def.getId())
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(226).setHeight(38))
|
||||||
|
.onClick((ignored, ui) -> open(player, def.getId(), "", "", "", sort, store, manager)),
|
||||||
|
288, 226, 38));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GroupBuilder detail = surface(
|
||||||
|
selected == null ? "LEADERBOARD" : selected.getName(),
|
||||||
|
subtitle(mapId, variantId, teamId, sort),
|
||||||
|
600,
|
||||||
|
474
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected == null) {
|
||||||
|
detail.addChild(inset(caption("Select a minigame to view stats.", 538), 600, 538, 24));
|
||||||
|
} else {
|
||||||
|
detail.addChild(filters(player, selected.getId(), mapId, variantId, teamId, sort, store, manager));
|
||||||
|
detail.addChild(spacer(600, 8));
|
||||||
|
detail.addChild(sortButtons(player, selected.getId(), mapId, variantId, teamId, sort, store, manager));
|
||||||
|
detail.addChild(spacer(600, 8));
|
||||||
|
|
||||||
|
List<MinigameStatsRepository.BreakdownRow> rows = services.stats()
|
||||||
|
.topPlayers(selected.getId(), mapId, variantId, teamId, sort, LIMIT);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
detail.addChild(inset(caption("No finished-game stats match this selection yet.", 538), 600, 538, 24));
|
||||||
|
} else {
|
||||||
|
detail.addChild(tableHeader());
|
||||||
|
int rank = 1;
|
||||||
|
for (MinigameStatsRepository.BreakdownRow row : rows) {
|
||||||
|
detail.addChild(row(rank++, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ContainerBuilder container = compactDecoratedContainer("Minigame Stats", 940, 560)
|
||||||
|
.withPadding(HyUIPadding.symmetric(18, 14))
|
||||||
|
.withLayoutMode("Top")
|
||||||
|
.withBackground(patch("#101722"))
|
||||||
|
.addContentChild(GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(904).setHeight(474))
|
||||||
|
.addChild(gameList)
|
||||||
|
.addChild(spacer(16, 474))
|
||||||
|
.addChild(detail));
|
||||||
|
|
||||||
|
PageBuilder.pageForPlayer(player).addElement(container).open(player, store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBuilder filters(
|
||||||
|
PlayerRef player,
|
||||||
|
String minigameId,
|
||||||
|
String mapId,
|
||||||
|
String variantId,
|
||||||
|
String teamId,
|
||||||
|
String sort,
|
||||||
|
Store<EntityStore> store,
|
||||||
|
@Nullable TriggerVolumeManager manager
|
||||||
|
) {
|
||||||
|
GroupBuilder group = GroupBuilder.group()
|
||||||
|
.withLayoutMode("Top")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(118));
|
||||||
|
|
||||||
|
group.addChild(filterRow("Map", valuesFor(manager, minigameId, "map"), mapId, value ->
|
||||||
|
open(player, minigameId, value, "", "", sort, store, manager)));
|
||||||
|
group.addChild(filterRow("Variant", valuesFor(manager, minigameId, "variant"), variantId, value ->
|
||||||
|
open(player, minigameId, "", value, "", sort, store, manager)));
|
||||||
|
group.addChild(filterRow("Team", valuesFor(manager, minigameId, "team"), teamId, value ->
|
||||||
|
open(player, minigameId, "", "", value, sort, store, manager)));
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBuilder filterRow(String label, List<String> values, String selected, java.util.function.Consumer<String> click) {
|
||||||
|
GroupBuilder row = GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(36))
|
||||||
|
.addChild(spacer(SECTION_INSET, 36))
|
||||||
|
.addChild(kicker(label, 62))
|
||||||
|
.addChild(button("All", selected.isBlank(), 76, () -> click.accept("")));
|
||||||
|
|
||||||
|
for (String value : values.stream().limit(4).toList()) {
|
||||||
|
row.addChild(spacer(6, 36));
|
||||||
|
row.addChild(button(prettify(value), value.equals(selected), 96, () -> click.accept(value)));
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private GroupBuilder sortButtons(
|
||||||
|
PlayerRef player,
|
||||||
|
String minigameId,
|
||||||
|
String mapId,
|
||||||
|
String variantId,
|
||||||
|
String teamId,
|
||||||
|
String sort,
|
||||||
|
Store<EntityStore> store,
|
||||||
|
@Nullable TriggerVolumeManager manager
|
||||||
|
) {
|
||||||
|
GroupBuilder row = GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(38))
|
||||||
|
.addChild(spacer(SECTION_INSET, 38))
|
||||||
|
.addChild(button("Score", "score_total".equals(sort), 92,
|
||||||
|
() -> open(player, minigameId, mapId, variantId, teamId, "score_total", store, manager)))
|
||||||
|
.addChild(spacer(6, 38))
|
||||||
|
.addChild(button("Wins", "wins".equals(sort), 92,
|
||||||
|
() -> open(player, minigameId, mapId, variantId, teamId, "wins", store, manager)))
|
||||||
|
.addChild(spacer(6, 38))
|
||||||
|
.addChild(button("Kills", "kills".equals(sort), 92,
|
||||||
|
() -> open(player, minigameId, mapId, variantId, teamId, "kills", store, manager)))
|
||||||
|
.addChild(spacer(6, 38))
|
||||||
|
.addChild(button("Games", "games_played".equals(sort), 92,
|
||||||
|
() -> open(player, minigameId, mapId, variantId, teamId, "games_played", store, manager)))
|
||||||
|
.addChild(spacer(6, 38))
|
||||||
|
.addChild(button("Time", "playtime_ms".equals(sort), 92,
|
||||||
|
() -> open(player, minigameId, mapId, variantId, teamId, "playtime_ms", store, manager)));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> valuesFor(@Nullable TriggerVolumeManager manager, String minigameId, String dimension) {
|
||||||
|
Set<String> values = new LinkedHashSet<>();
|
||||||
|
if ("map".equals(dimension)) {
|
||||||
|
values.addAll(activeMapIds(manager, minigameId));
|
||||||
|
} else if ("variant".equals(dimension)) {
|
||||||
|
values.addAll(activeVariantIds(manager, minigameId));
|
||||||
|
}
|
||||||
|
values.addAll(services.stats().distinctDimensionValues(minigameId, dimension));
|
||||||
|
return new ArrayList<>(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> activeMapIds(@Nullable TriggerVolumeManager manager, String minigameId) {
|
||||||
|
if (manager == null) return List.of();
|
||||||
|
return manager.getVolumes().stream()
|
||||||
|
.filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
|
||||||
|
.filter(v -> MinigameMapDiscoveryService.ROLE_MAIN_ARENA.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MAP_ROLE)))
|
||||||
|
.map(v -> v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_MAP_ID, ""))
|
||||||
|
.filter(id -> !id.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> activeVariantIds(@Nullable TriggerVolumeManager manager, String minigameId) {
|
||||||
|
if (manager == null) return List.of();
|
||||||
|
return manager.getVolumes().stream()
|
||||||
|
.filter(v -> minigameId.equals(v.getRawTags().get(MinigameMapDiscoveryService.TAG_MINIGAME_ID)))
|
||||||
|
.map(v -> v.getRawTags().getOrDefault(MinigameMapDiscoveryService.TAG_VARIANT_ID, ""))
|
||||||
|
.filter(id -> !id.isBlank())
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupBuilder tableHeader() {
|
||||||
|
return GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(24))
|
||||||
|
.addChild(spacer(SECTION_INSET, 24))
|
||||||
|
.addChild(kicker("#", 26))
|
||||||
|
.addChild(kicker("PLAYER", 178))
|
||||||
|
.addChild(kicker("SCORE", 74))
|
||||||
|
.addChild(kicker("W", 42))
|
||||||
|
.addChild(kicker("K/D/A", 88))
|
||||||
|
.addChild(kicker("GAMES", 54))
|
||||||
|
.addChild(kicker("TIME", 70));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupBuilder row(int rank, MinigameStatsRepository.BreakdownRow row) {
|
||||||
|
String name = row.playerUsername() == null || row.playerUsername().isBlank() ? row.playerUuid() : row.playerUsername();
|
||||||
|
String kda = row.kills() + "/" + row.deaths() + "/" + row.assists();
|
||||||
|
return GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(600).setHeight(30))
|
||||||
|
.addChild(spacer(SECTION_INSET, 30))
|
||||||
|
.addChild(bodyLabel(Integer.toString(rank), 26))
|
||||||
|
.addChild(bodyLabel(name, 178))
|
||||||
|
.addChild(bodyLabel(Long.toString(row.scoreTotal()), 74))
|
||||||
|
.addChild(bodyLabel(Long.toString(row.wins()), 42))
|
||||||
|
.addChild(bodyLabel(kda, 88))
|
||||||
|
.addChild(bodyLabel(Long.toString(row.gamesPlayed()), 54))
|
||||||
|
.addChild(bodyLabel(formatTime(row.playtimeMs()), 70));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ButtonBuilder button(String text, boolean selected, int width, Runnable action) {
|
||||||
|
return ButtonBuilder.secondaryTextButton()
|
||||||
|
.withText(selected ? "> " + text : text)
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(32))
|
||||||
|
.onClick(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String subtitle(String mapId, String variantId, String teamId, String sort) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
if (!mapId.isBlank()) parts.add("map " + prettify(mapId));
|
||||||
|
if (!variantId.isBlank()) parts.add("variant " + prettify(variantId));
|
||||||
|
if (!teamId.isBlank()) parts.add("team " + prettify(teamId));
|
||||||
|
parts.add("sorted by " + prettify(sort));
|
||||||
|
return String.join(" | ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatTime(long millis) {
|
||||||
|
long minutes = Math.max(0, millis / 60_000L);
|
||||||
|
long hours = minutes / 60L;
|
||||||
|
long mins = minutes % 60L;
|
||||||
|
return hours > 0 ? hours + "h " + mins + "m" : mins + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String prettify(String id) {
|
||||||
|
if (id == null || id.isBlank()) return "All";
|
||||||
|
return Arrays.stream(id.replace('_', ' ').split("\\s+"))
|
||||||
|
.map(w -> w.isEmpty() ? w : Character.toUpperCase(w.charAt(0)) + w.substring(1).toLowerCase())
|
||||||
|
.collect(Collectors.joining(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ContainerBuilder compactDecoratedContainer(String title, int width, int height) {
|
||||||
|
return ContainerBuilder.decoratedContainer()
|
||||||
|
.withTitleText(title)
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||||
|
.editElementAfter((commands, selector) -> commands.setObject(
|
||||||
|
selector + " #Content.Anchor",
|
||||||
|
new HyUIAnchor().setTop(0).toHytaleAnchor()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupBuilder surface(String title, String subtitle, int width, int height) {
|
||||||
|
return GroupBuilder.group()
|
||||||
|
.withLayoutMode("Top")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||||
|
.withBackground(patch("#172131"))
|
||||||
|
.withOutlineColor("#2b405d")
|
||||||
|
.withOutlineSize(1.0f)
|
||||||
|
.addChild(spacer(width, 8))
|
||||||
|
.addChild(inset(kicker(title, Math.max(80, width - 44)), width, Math.max(80, width - 44), 18))
|
||||||
|
.addChild(inset(caption(subtitle, Math.max(80, width - 44)), width, Math.max(80, width - 44), 24))
|
||||||
|
.addChild(spacer(width, 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupBuilder inset(UIElementBuilder<?> child, int outerWidth, int childWidth, int height) {
|
||||||
|
return GroupBuilder.group()
|
||||||
|
.withLayoutMode("Left")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(outerWidth).setHeight(height))
|
||||||
|
.addChild(spacer(SECTION_INSET, height))
|
||||||
|
.addChild(child.withAnchor(new HyUIAnchor().setWidth(childWidth).setHeight(height)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LabelBuilder kicker(String text, int width) {
|
||||||
|
return LabelBuilder.label()
|
||||||
|
.withText(text)
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(18))
|
||||||
|
.withStyle(textStyle(11, "#7cc7ff", true)
|
||||||
|
.setRenderUppercase(true).setWrap(false)
|
||||||
|
.setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LabelBuilder bodyLabel(String text, int width) {
|
||||||
|
return LabelBuilder.label()
|
||||||
|
.withText(text)
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(20))
|
||||||
|
.withStyle(textStyle(12, "#dbe7f6", false)
|
||||||
|
.setWrap(false).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LabelBuilder caption(String text, int width) {
|
||||||
|
return LabelBuilder.label()
|
||||||
|
.withText(text)
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(24))
|
||||||
|
.withStyle(textStyle(11, "#9eb4cf", false)
|
||||||
|
.setWrap(true).setShrinkTextToFit(true).setMinShrinkTextToFitFontSize(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LabelBuilder spacer(int width, int height) {
|
||||||
|
return LabelBuilder.label()
|
||||||
|
.withText(" ")
|
||||||
|
.withAnchor(new HyUIAnchor().setWidth(width).setHeight(height))
|
||||||
|
.withStyle(textStyle(1, "#172131", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HyUIStyle textStyle(float size, String color, boolean bold) {
|
||||||
|
return new HyUIStyle()
|
||||||
|
.setFontSize(size)
|
||||||
|
.setTextColor(color)
|
||||||
|
.setRenderBold(bold)
|
||||||
|
.setLetterSpacing(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HyUIPatchStyle patch(String color) {
|
||||||
|
return new HyUIPatchStyle().setColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PendingOpen(UUID uuid, String minigameId, long createdAtMillis) {}
|
||||||
|
}
|
||||||
@@ -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() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,13 @@ import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
|||||||
import com.hypixel.hytale.codec.Codec;
|
import com.hypixel.hytale.codec.Codec;
|
||||||
import com.hypixel.hytale.codec.KeyedCodec;
|
import com.hypixel.hytale.codec.KeyedCodec;
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
import com.hypixel.hytale.server.core.Message;
|
import net.kewwbec.minigames.action.MinigameActionContext;
|
||||||
import com.hypixel.hytale.server.core.entity.UUIDComponent;
|
import net.kewwbec.minigames.action.MinigameActionSupport;
|
||||||
import com.hypixel.hytale.server.core.universe.PlayerRef;
|
|
||||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.awt.Color;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
||||||
@@ -38,103 +34,55 @@ public abstract class MinigameRuntimeEffect extends TriggerEffect {
|
|||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
protected Optional<MinigameRuntime> runtime(@Nonnull TriggerContext context) {
|
protected Optional<MinigameRuntime> runtime(@Nonnull TriggerContext context) {
|
||||||
MinigameServices services = MinigameCorePlugin.getServices();
|
return runtime(actionContext(context));
|
||||||
if (services == null) {
|
}
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
|
||||||
String declaredId = !candidate.minigameId().isBlank()
|
|
||||||
? candidate.minigameId()
|
|
||||||
: (minigameId != null ? minigameId : "");
|
|
||||||
|
|
||||||
String playerId = resolvePlayerId(context, null);
|
@Nonnull
|
||||||
if (playerId != null) {
|
protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
|
||||||
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
return MinigameActionSupport.runtime(context, minigameId);
|
||||||
// Player-first resolution must not hijack a volume that declares a different
|
|
||||||
// minigame: a player from game B walking through game A's volume targets game A.
|
|
||||||
if (playerRuntime.isPresent()
|
|
||||||
&& (declaredId.isBlank() || playerRuntime.get().minigameId().equals(declaredId))) {
|
|
||||||
return playerRuntime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (declaredId.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
|
||||||
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
protected static MinigameServices services() {
|
protected static MinigameServices services() {
|
||||||
return MinigameCorePlugin.getServices();
|
return MinigameActionSupport.services();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
protected String resolvedMinigameId(@Nonnull TriggerContext context) {
|
protected String resolvedMinigameId(@Nonnull TriggerContext context) {
|
||||||
var services = services();
|
return resolvedMinigameId(actionContext(context));
|
||||||
if (services != null) {
|
}
|
||||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
|
||||||
if (!candidate.minigameId().isBlank()) {
|
@Nonnull
|
||||||
return candidate.minigameId();
|
protected String resolvedMinigameId(@Nonnull MinigameActionContext context) {
|
||||||
}
|
return MinigameActionSupport.resolvedMinigameId(context, minigameId);
|
||||||
if (minigameId == null || minigameId.isBlank()) {
|
|
||||||
String playerId = resolvePlayerId(context, null);
|
|
||||||
if (playerId != null) {
|
|
||||||
var playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
|
||||||
if (playerRuntime.isPresent()) {
|
|
||||||
return playerRuntime.get().minigameId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return minigameId != null ? minigameId : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) {
|
protected void debugMessage(@Nonnull TriggerContext context, @Nonnull String effectType, @Nonnull String details) {
|
||||||
String id = resolvedMinigameId(context);
|
debugMessage(actionContext(context), effectType, details);
|
||||||
if (id.isBlank()) return;
|
}
|
||||||
var def = MinigameDefinition.getAssetMap().getAsset(id);
|
|
||||||
if (def == null || !def.isDebug()) return;
|
protected void debugMessage(@Nonnull MinigameActionContext context, @Nonnull String effectType, @Nonnull String details) {
|
||||||
long now = System.currentTimeMillis();
|
lastDebugMs = MinigameActionSupport.debugMessage(context, minigameId, effectType, details, lastDebugMs);
|
||||||
if (now - lastDebugMs < 5000L) return;
|
|
||||||
lastDebugMs = now;
|
|
||||||
PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
|
||||||
if (playerRef != null) {
|
|
||||||
playerRef.sendMessage(
|
|
||||||
Message.translation("minigames.debug.effect")
|
|
||||||
.param("type", effectType)
|
|
||||||
.param("details", details)
|
|
||||||
.color(Color.CYAN)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Player username for debug output, falling back to the raw id when offline/unresolved. */
|
/** Player username for debug output, falling back to the raw id when offline/unresolved. */
|
||||||
@Nonnull
|
@Nonnull
|
||||||
protected static String displayName(@Nonnull String playerId) {
|
protected static String displayName(@Nonnull String playerId) {
|
||||||
MinigameServices services = services();
|
return MinigameActionSupport.displayName(playerId);
|
||||||
if (services != null) {
|
|
||||||
String name = services.adapters().getDisplayName(playerId);
|
|
||||||
if (name != null && !name.isBlank()) {
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return playerId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) {
|
protected static String resolvePlayerId(@Nonnull TriggerContext context, @Nullable String configuredPlayerId) {
|
||||||
if (configuredPlayerId != null && !configuredPlayerId.isBlank()) {
|
return resolvePlayerId(MinigameActionContext.fromTrigger(context, null), configuredPlayerId);
|
||||||
return configuredPlayerId;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
PlayerRef playerRef = context.getStore().getComponent(context.getEntityRef(), PlayerRef.getComponentType());
|
@Nullable
|
||||||
if (playerRef != null) {
|
protected static String resolvePlayerId(@Nonnull MinigameActionContext context, @Nullable String configuredPlayerId) {
|
||||||
return playerRef.getUuid().toString();
|
return MinigameActionSupport.resolvePlayerId(context, configuredPlayerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
UUIDComponent uuidComponent = context.getStore().getComponent(context.getEntityRef(), UUIDComponent.getComponentType());
|
@Nonnull
|
||||||
return uuidComponent != null ? uuidComponent.getUuid().toString() : null;
|
protected MinigameActionContext actionContext(@Nonnull TriggerContext context) {
|
||||||
|
return MinigameActionContext.fromTrigger(context, minigameId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ public final class ModifyScoreEffect extends MinigameRuntimeEffect {
|
|||||||
return player != null ? clean(player.teamId()) : "";
|
return player != null ? clean(player.teamId()) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
static int apply(int current, Operation operation, int amount) {
|
public static int apply(int current, Operation operation, int amount) {
|
||||||
return switch (operation != null ? operation : Operation.ADD) {
|
return switch (operation != null ? operation : Operation.ADD) {
|
||||||
case SET -> amount;
|
case SET -> amount;
|
||||||
case ADD -> current + amount;
|
case ADD -> current + amount;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.hypixel.hytale.codec.KeyedCodec;
|
|||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
import net.kewwbec.minigames.model.ItemStackConfig;
|
import net.kewwbec.minigames.model.ItemStackConfig;
|
||||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
import net.kewwbec.minigames.model.LoadoutConfig;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -38,8 +39,7 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = rt.get().players().get(id);
|
if (rt.get().players().get(id) == null) {
|
||||||
if (player == null) {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -50,44 +50,53 @@ public final class SetPlayerLoadoutEffect extends MinigameRuntimeEffect {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items are only ever granted through this effect — nothing is given automatically
|
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' " + grantLoadout(services, rt.get(), id, loadoutId));
|
||||||
// on activation. A blank LoadoutId grants the definition's StartItems instead.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grants a loadout's items to a player already present in {@code runtime}. A blank {@code loadoutId}
|
||||||
|
* grants the definition's StartItems. Items are only ever granted through here — never automatically
|
||||||
|
* on activation. Returns a short outcome string for logging. Reused by mid-game late-join equipping.
|
||||||
|
*/
|
||||||
|
public static String grantLoadout(@Nonnull MinigameServices services, @Nonnull net.kewwbec.minigames.runtime.MinigameRuntime runtime, @Nonnull String playerId, String loadoutId) {
|
||||||
|
var player = runtime.players().get(playerId);
|
||||||
|
if (player == null) {
|
||||||
|
return "SKIPPED (not in runtime)";
|
||||||
|
}
|
||||||
|
|
||||||
if (loadoutId == null || loadoutId.isBlank()) {
|
if (loadoutId == null || loadoutId.isBlank()) {
|
||||||
for (ItemStackConfig item : rt.get().definition().getStartItems()) {
|
for (ItemStackConfig item : runtime.definition().getStartItems()) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' granted StartItems");
|
return "granted StartItems";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LoadoutConfig selected = null;
|
LoadoutConfig selected = null;
|
||||||
for (LoadoutConfig loadout : rt.get().definition().getStartLoadouts()) {
|
for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
|
||||||
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
if (loadout != null && loadoutId.equals(loadout.getId())) {
|
||||||
selected = loadout;
|
selected = loadout;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (selected == null) {
|
if (selected == null) {
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (loadout='" + loadoutId + "' not found in definition)");
|
return "SKIPPED (loadout='" + loadoutId + "' not found in definition)";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String previous = player.loadoutId();
|
String previous = player.loadoutId();
|
||||||
player.loadoutId(loadoutId);
|
player.loadoutId(loadoutId);
|
||||||
|
|
||||||
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
if (selected.isPlayerCustomizable() && services.menus() != null) {
|
||||||
services.menus().requestLoadoutSelection(id, rt.get());
|
services.menus().requestLoadoutSelection(playerId, runtime);
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' opened loadout selection");
|
return "opened loadout selection";
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ItemStackConfig item : selected.getItems()) {
|
for (ItemStackConfig item : selected.getItems()) {
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
services.adapters().giveItem(id, item.getItemId(), item.getAmount());
|
services.adapters().giveItem(playerId, item.getItemId(), item.getAmount());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' loadout " + previous + "->" + loadoutId);
|
return "loadout " + previous + "->" + loadoutId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,21 +38,29 @@ public final class SetPlayerStatusEffect extends MinigameRuntimeEffect {
|
|||||||
var player = rt.get().players().get(id);
|
var player = rt.get().players().get(id);
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
|
PlayerStatus resolvedStatus = status != null ? status : PlayerStatus.ACTIVE;
|
||||||
|
var svc = services();
|
||||||
|
boolean wasSpectator = rt.get().spectators().contains(id);
|
||||||
player.status(resolvedStatus);
|
player.status(resolvedStatus);
|
||||||
if (resolvedStatus == PlayerStatus.SPECTATOR) {
|
if (resolvedStatus == PlayerStatus.SPECTATOR) {
|
||||||
rt.get().spectators().add(id);
|
if (svc != null) {
|
||||||
|
svc.runtime().onPlayerSpectate(rt.get(), id);
|
||||||
|
} else {
|
||||||
|
rt.get().spectators().add(id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
rt.get().spectators().remove(id);
|
rt.get().spectators().remove(id);
|
||||||
|
// ACTIVE clears spectator presentation itself (onPlayerActivated); handle other exits here.
|
||||||
|
if (wasSpectator && resolvedStatus != PlayerStatus.ACTIVE && svc != null) {
|
||||||
|
svc.adapters().clearSpectatorState(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (resolvedStatus == PlayerStatus.ACTIVE) {
|
if (resolvedStatus == PlayerStatus.ACTIVE) {
|
||||||
rt.get().eliminatedPlayers().remove(id);
|
rt.get().eliminatedPlayers().remove(id);
|
||||||
var svc = services();
|
|
||||||
if (svc != null) {
|
if (svc != null) {
|
||||||
svc.runtime().onPlayerActivated(rt.get(), id);
|
svc.runtime().onPlayerActivated(rt.get(), id);
|
||||||
}
|
}
|
||||||
} else if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
} else if (resolvedStatus == PlayerStatus.ELIMINATED) {
|
||||||
rt.get().eliminatedPlayers().add(id);
|
rt.get().eliminatedPlayers().add(id);
|
||||||
var svc = services();
|
|
||||||
if (svc != null) {
|
if (svc != null) {
|
||||||
svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id));
|
svc.runtime().dispatch(rt.get(), "on_player_eliminated", Map.of("player_id", id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
|||||||
.add()
|
.add()
|
||||||
.<Integer>append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
.<Integer>append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
.add()
|
.add()
|
||||||
.<Integer>append(
|
.<Double>append(
|
||||||
new KeyedCodec<>("RespawnDelaySeconds", Codec.INTEGER, false),
|
new KeyedCodec<>("RespawnDelaySeconds", Codec.DOUBLE, false),
|
||||||
(effect, value) -> effect.respawnDelaySeconds = value,
|
(effect, value) -> effect.respawnDelaySeconds = value,
|
||||||
effect -> effect.respawnDelaySeconds
|
effect -> effect.respawnDelaySeconds
|
||||||
)
|
)
|
||||||
@@ -51,7 +51,7 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
|||||||
|
|
||||||
private String itemId = "";
|
private String itemId = "";
|
||||||
private int amount = 1;
|
private int amount = 1;
|
||||||
private int respawnDelaySeconds = 0;
|
private double respawnDelaySeconds = 0.0D;
|
||||||
private String spawnKey = "";
|
private String spawnKey = "";
|
||||||
private double offsetX = 0.0D;
|
private double offsetX = 0.0D;
|
||||||
private double offsetY = 0.0D;
|
private double offsetY = 0.0D;
|
||||||
@@ -87,9 +87,9 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
|||||||
|
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
activeItems.remove(key);
|
activeItems.remove(key);
|
||||||
int delay = Math.max(0, respawnDelaySeconds);
|
Duration delay = respawnDelay();
|
||||||
if (delay > 0 && !nextSpawnTimes.containsKey(key)) {
|
if (!delay.isZero() && !nextSpawnTimes.containsKey(key)) {
|
||||||
nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay)));
|
nextSpawnTimes.put(key, Instant.now().plus(delay));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,9 +128,9 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
|||||||
activeItems.put(key, ref);
|
activeItems.put(key, ref);
|
||||||
nextSpawnTimes.remove(key);
|
nextSpawnTimes.remove(key);
|
||||||
if (ignoreRespawnTimer) {
|
if (ignoreRespawnTimer) {
|
||||||
int delay = Math.max(0, respawnDelaySeconds);
|
Duration delay = respawnDelay();
|
||||||
if (delay > 0) {
|
if (!delay.isZero()) {
|
||||||
nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay)));
|
nextSpawnTimes.put(key, Instant.now().plus(delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runtime(context).ifPresent(runtime -> {
|
runtime(context).ifPresent(runtime -> {
|
||||||
@@ -142,6 +142,15 @@ public final class SpawnItemEffect extends MinigameRuntimeEffect {
|
|||||||
debugMessage(context, TYPE_ID, "FIRED spawned item='" + itemId + "' amount=" + Math.max(1, amount));
|
debugMessage(context, TYPE_ID, "FIRED spawned item='" + itemId + "' amount=" + Math.max(1, amount));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private Duration respawnDelay() {
|
||||||
|
double seconds = Math.max(0.0D, respawnDelaySeconds);
|
||||||
|
if (seconds <= 0.0D) {
|
||||||
|
return Duration.ZERO;
|
||||||
|
}
|
||||||
|
return Duration.ofNanos(Math.max(1L, Math.round(seconds * 1_000_000_000.0D)));
|
||||||
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
private String key(@Nonnull TriggerContext context) {
|
private String key(@Nonnull TriggerContext context) {
|
||||||
String configured = spawnKey != null ? spawnKey.trim() : "";
|
String configured = spawnKey != null ? spawnKey.trim() : "";
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
|||||||
.add()
|
.add()
|
||||||
.<Integer>append(new KeyedCodec<>("Count", Codec.INTEGER, false), (effect, value) -> effect.count = value, effect -> effect.count)
|
.<Integer>append(new KeyedCodec<>("Count", Codec.INTEGER, false), (effect, value) -> effect.count = value, effect -> effect.count)
|
||||||
.add()
|
.add()
|
||||||
.<Integer>append(
|
.<Double>append(
|
||||||
new KeyedCodec<>("RespawnDelaySeconds", Codec.INTEGER, false),
|
new KeyedCodec<>("RespawnDelaySeconds", Codec.DOUBLE, false),
|
||||||
(effect, value) -> effect.respawnDelaySeconds = value,
|
(effect, value) -> effect.respawnDelaySeconds = value,
|
||||||
effect -> effect.respawnDelaySeconds
|
effect -> effect.respawnDelaySeconds
|
||||||
)
|
)
|
||||||
@@ -61,7 +61,7 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
|||||||
|
|
||||||
private String roleId = "";
|
private String roleId = "";
|
||||||
private int count = 1;
|
private int count = 1;
|
||||||
private int respawnDelaySeconds = 0;
|
private double respawnDelaySeconds = 0.0D;
|
||||||
private String spawnKey = "";
|
private String spawnKey = "";
|
||||||
private double offsetX = 0.0D;
|
private double offsetX = 0.0D;
|
||||||
private double offsetY = 0.0D;
|
private double offsetY = 0.0D;
|
||||||
@@ -100,9 +100,9 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextSpawn == null && activeNpcs.containsKey(key)) {
|
if (nextSpawn == null && activeNpcs.containsKey(key)) {
|
||||||
int delay = Math.max(0, respawnDelaySeconds);
|
Duration delay = respawnDelay();
|
||||||
if (delay > 0) {
|
if (!delay.isZero()) {
|
||||||
nextSpawnTimes.put(key, Instant.now().plus(Duration.ofSeconds(delay)));
|
nextSpawnTimes.put(key, Instant.now().plus(delay));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +199,15 @@ public final class SpawnNpcEffect extends MinigameRuntimeEffect {
|
|||||||
return runtimeSegment + "|" + volumeId + "|" + (!configured.isBlank() ? configured : roleId);
|
return runtimeSegment + "|" + volumeId + "|" + (!configured.isBlank() ? configured : roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private Duration respawnDelay() {
|
||||||
|
double seconds = Math.max(0.0D, respawnDelaySeconds);
|
||||||
|
if (seconds <= 0.0D) {
|
||||||
|
return Duration.ZERO;
|
||||||
|
}
|
||||||
|
return Duration.ofNanos(Math.max(1L, Math.round(seconds * 1_000_000_000.0D)));
|
||||||
|
}
|
||||||
|
|
||||||
/** Drops state belonging to runtimes that no longer exist. */
|
/** Drops state belonging to runtimes that no longer exist. */
|
||||||
static void pruneStaleRuntimeKeys(Map<String, ?>... maps) {
|
static void pruneStaleRuntimeKeys(Map<String, ?>... maps) {
|
||||||
var services = services();
|
var services = services();
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ command.spec.leave.usage = /minigame leave
|
|||||||
command.spec.leave.description = Leave the current minigame.
|
command.spec.leave.description = Leave the current minigame.
|
||||||
command.spec.spectate.usage = /minigame spectate <id>
|
command.spec.spectate.usage = /minigame spectate <id>
|
||||||
command.spec.spectate.description = Join as a spectator.
|
command.spec.spectate.description = Join as a spectator.
|
||||||
|
command.spec.stats.usage = /minigame stats [id]
|
||||||
|
command.spec.stats.description = Open the minigame leaderboard and stats UI.
|
||||||
command.spec.volume.create.usage = /minigame volume create <type>
|
command.spec.volume.create.usage = /minigame volume create <type>
|
||||||
command.spec.volume.create.description = Create a minigame volume.
|
command.spec.volume.create.description = Create a minigame volume.
|
||||||
command.spec.volume.link.usage = /minigame volume link <volume_id> <minigame_id>
|
command.spec.volume.link.usage = /minigame volume link <volume_id> <minigame_id>
|
||||||
@@ -59,6 +61,7 @@ command.spec.enable.arg.id = Minigame ID
|
|||||||
command.spec.disable.arg.id = Minigame ID
|
command.spec.disable.arg.id = Minigame ID
|
||||||
command.spec.reset.arg.id = Minigame ID
|
command.spec.reset.arg.id = Minigame ID
|
||||||
command.spec.validate.arg.id = Minigame ID
|
command.spec.validate.arg.id = Minigame ID
|
||||||
|
command.spec.stats.arg.id = Optional minigame ID to open first
|
||||||
|
|
||||||
command.error.not_found = Minigame '{id}' was not found.
|
command.error.not_found = Minigame '{id}' was not found.
|
||||||
command.error.already_exists = A minigame with id '{id}' already exists.
|
command.error.already_exists = A minigame with id '{id}' already exists.
|
||||||
@@ -162,6 +165,10 @@ command.edit.error.unknown_property = Unknown property '{property}'. Valid prope
|
|||||||
command.edit.error.save_failed = Could not save '{id}': {error}
|
command.edit.error.save_failed = Could not save '{id}': {error}
|
||||||
command.join.success = Joined the queue for {id}.
|
command.join.success = Joined the queue for {id}.
|
||||||
command.join.already_queued = You are already queued for {id}.
|
command.join.already_queued = You are already queued for {id}.
|
||||||
|
command.join.already_in_game = You are already in a minigame.
|
||||||
|
command.join.midgame = Joined {id} in progress.
|
||||||
|
command.join.as_spectator = {id} is in progress — joined as a spectator.
|
||||||
|
command.join.in_progress = {id} has already started and does not allow joining or spectating.
|
||||||
command.leave.success = You left {id}.
|
command.leave.success = You left {id}.
|
||||||
command.leave.not_in_game = You are not in a minigame.
|
command.leave.not_in_game = You are not in a minigame.
|
||||||
command.spectate.success = Now spectating {id}.
|
command.spectate.success = Now spectating {id}.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ customUI.triggerVolumeEffectEditor.field.common.MapId.tooltip = The map id to vo
|
|||||||
customUI.triggerVolumeEffectEditor.field.common.ItemId = Item
|
customUI.triggerVolumeEffectEditor.field.common.ItemId = Item
|
||||||
customUI.triggerVolumeEffectEditor.field.common.ItemId.tooltip = The item asset id to spawn on the ground.
|
customUI.triggerVolumeEffectEditor.field.common.ItemId.tooltip = The item asset id to spawn on the ground.
|
||||||
customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds = Respawn Delay
|
customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds = Respawn Delay
|
||||||
customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds.tooltip = Seconds to wait after the spawned item is picked up or despawns before spawning again.
|
customUI.triggerVolumeEffectEditor.field.common.RespawnDelaySeconds.tooltip = Seconds to wait before spawning again. Decimals are supported, e.g. 0.5.
|
||||||
customUI.triggerVolumeEffectEditor.field.common.SpawnKey = Spawn Key
|
customUI.triggerVolumeEffectEditor.field.common.SpawnKey = Spawn Key
|
||||||
customUI.triggerVolumeEffectEditor.field.common.SpawnKey.tooltip = Optional unique id for this spawn point when one volume has multiple item spawners.
|
customUI.triggerVolumeEffectEditor.field.common.SpawnKey.tooltip = Optional unique id for this spawn point when one volume has multiple item spawners.
|
||||||
customUI.triggerVolumeEffectEditor.field.common.OffsetX = Offset X
|
customUI.triggerVolumeEffectEditor.field.common.OffsetX = Offset X
|
||||||
@@ -64,7 +64,7 @@ customUI.triggerVolumeEffectEditor.field.StartQueuedMinigame.MinigameId.tooltip
|
|||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.MinigameId.tooltip = Optional minigame runtime to associate this spawned pickup with.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.MinigameId.tooltip = Optional minigame runtime to associate this spawned pickup with.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.ItemId.tooltip = The item asset id to spawn at this trigger volume.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.ItemId.tooltip = The item asset id to spawn at this trigger volume.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.Amount.tooltip = Number of items in the spawned stack.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.Amount.tooltip = Number of items in the spawned stack.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after the previous item is picked up or despawns.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after the previous item is picked up or despawns. Decimals are supported.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.SpawnKey.tooltip = Optional unique id for this item spawner.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.SpawnKey.tooltip = Optional unique id for this item spawner.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetX.tooltip = Spawn position offset on the X axis.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetX.tooltip = Spawn position offset on the X axis.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetY.tooltip = Spawn position offset on the Y axis.
|
customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetY.tooltip = Spawn position offset on the Y axis.
|
||||||
@@ -72,7 +72,7 @@ customUI.triggerVolumeEffectEditor.field.SpawnItem.OffsetZ.tooltip = Spawn posit
|
|||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.MinigameId.tooltip = Optional minigame runtime to associate this spawned NPC with.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.MinigameId.tooltip = Optional minigame runtime to associate this spawned NPC with.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RoleId.tooltip = The spawnable NPC role id to spawn.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RoleId.tooltip = The spawnable NPC role id to spawn.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Count.tooltip = Number of NPCs to keep active for this spawn key.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.Count.tooltip = Number of NPCs to keep active for this spawn key.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after all active NPCs for this spawn key are gone.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.RespawnDelaySeconds.tooltip = Seconds to wait before respawning after all active NPCs for this spawn key are gone. Decimals are supported.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.SpawnKey.tooltip = Optional unique id for this NPC spawner.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.SpawnKey.tooltip = Optional unique id for this NPC spawner.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetX.tooltip = Spawn position offset on the X axis.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetX.tooltip = Spawn position offset on the X axis.
|
||||||
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetY.tooltip = Spawn position offset on the Y axis.
|
customUI.triggerVolumeEffectEditor.field.SpawnNpc.OffsetY.tooltip = Spawn position offset on the Y axis.
|
||||||
@@ -176,7 +176,7 @@ customUI.triggerVolumeEffectEditor.effectType.StartTimer = Start Timer
|
|||||||
customUI.triggerVolumeEffectEditor.effectType.StopTimer = Stop Timer
|
customUI.triggerVolumeEffectEditor.effectType.StopTimer = Stop Timer
|
||||||
customUI.triggerVolumeEffectEditor.effectType.SetSpawnPoint = Set Spawn Point
|
customUI.triggerVolumeEffectEditor.effectType.SetSpawnPoint = Set Spawn Point
|
||||||
customUI.triggerVolumeEffectEditor.effectType.RespawnPlayer = Respawn Player
|
customUI.triggerVolumeEffectEditor.effectType.RespawnPlayer = Respawn Player
|
||||||
customUI.triggerVolumeEffectEditor.effectType.SendMinigameMessage = Send Message
|
customUI.triggerVolumeEffectEditor.effectType.SendMinigameMessage = Send MiniGame Message
|
||||||
customUI.triggerVolumeEffectEditor.effectType.AssignTeam = Assign Team
|
customUI.triggerVolumeEffectEditor.effectType.AssignTeam = Assign Team
|
||||||
customUI.triggerVolumeEffectEditor.effectType.SaveInventory = Save Inventory
|
customUI.triggerVolumeEffectEditor.effectType.SaveInventory = Save Inventory
|
||||||
customUI.triggerVolumeEffectEditor.effectType.RestoreInventory = Restore Inventory
|
customUI.triggerVolumeEffectEditor.effectType.RestoreInventory = Restore Inventory
|
||||||
@@ -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.clonegroup.success = Cloned group '{source}' to '{name}' with {count} volumes
|
||||||
|
|
||||||
server.commands.triggervolume.create.alreadyExists = A trigger volume named '{name}' already exists
|
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,164 @@
|
|||||||
|
package net.kewwbec.minigames.interaction;
|
||||||
|
|
||||||
|
import net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
import net.kewwbec.minigames.volume.effect.*;
|
||||||
|
import org.bson.BsonBoolean;
|
||||||
|
import org.bson.BsonDocument;
|
||||||
|
import org.bson.BsonInt32;
|
||||||
|
import org.bson.BsonString;
|
||||||
|
import org.bson.BsonValue;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class MinigameInteractionsTest {
|
||||||
|
@Test
|
||||||
|
void includedTypeIdsMatchSafeRuntimeEffectScope() {
|
||||||
|
Set<String> expected = Stream.of(
|
||||||
|
StartMinigameEffect.TYPE_ID,
|
||||||
|
EndMinigameEffect.TYPE_ID,
|
||||||
|
ResetMinigameEffect.TYPE_ID,
|
||||||
|
SetMinigamePhaseEffect.TYPE_ID,
|
||||||
|
ResetScoresEffect.TYPE_ID,
|
||||||
|
ModifyScoreEffect.TYPE_ID,
|
||||||
|
ModifyCounterEffect.TYPE_ID,
|
||||||
|
ModifyLivesEffect.TYPE_ID,
|
||||||
|
SetPlayerStatusEffect.TYPE_ID,
|
||||||
|
SetPlayerLoadoutEffect.TYPE_ID,
|
||||||
|
JoinMinigameQueueEffect.TYPE_ID,
|
||||||
|
LeaveMinigameQueueEffect.TYPE_ID,
|
||||||
|
VoteForMapEffect.TYPE_ID,
|
||||||
|
StartQueuedMinigameEffect.TYPE_ID,
|
||||||
|
SetRoundEffect.TYPE_ID,
|
||||||
|
StartTimerEffect.TYPE_ID,
|
||||||
|
StopTimerEffect.TYPE_ID,
|
||||||
|
SetSpawnPointEffect.TYPE_ID,
|
||||||
|
RespawnPlayerEffect.TYPE_ID,
|
||||||
|
RespawnAllPlayersEffect.TYPE_ID,
|
||||||
|
SwapTeamsEffect.TYPE_ID,
|
||||||
|
SendMinigameMessageEffect.TYPE_ID,
|
||||||
|
AssignTeamEffect.TYPE_ID,
|
||||||
|
SaveInventoryEffect.TYPE_ID,
|
||||||
|
RestoreInventoryEffect.TYPE_ID,
|
||||||
|
ClearInventoryEffect.TYPE_ID,
|
||||||
|
SetObjectiveStatusEffect.TYPE_ID,
|
||||||
|
ModifyObjectiveProgressEffect.TYPE_ID,
|
||||||
|
OpenMinigameQueueUIEffect.TYPE_ID,
|
||||||
|
StartPlayerTimerEffect.TYPE_ID,
|
||||||
|
StopPlayerTimerEffect.TYPE_ID
|
||||||
|
)
|
||||||
|
.map(MinigameInteractions::interactionId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
assertEquals(expected, MinigameInteractions.includedTypeIds());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void excludedVolumeBoundEffectsDoNotHaveInteractionTypeIds() {
|
||||||
|
Set<String> ids = MinigameInteractions.includedTypeIds();
|
||||||
|
Stream.of(
|
||||||
|
AwardKillScoreEffect.TYPE_ID,
|
||||||
|
SpawnItemEffect.TYPE_ID,
|
||||||
|
SpawnNpcEffect.TYPE_ID,
|
||||||
|
MountPlayerEffect.TYPE_ID,
|
||||||
|
DismountPlayerEffect.TYPE_ID,
|
||||||
|
PlaceBlocksEffect.TYPE_ID
|
||||||
|
)
|
||||||
|
.map(MinigameInteractions::interactionId)
|
||||||
|
.forEach(id -> assertFalse(ids.contains(id), id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodesQueueJoinInteractionBaseFields() throws Exception {
|
||||||
|
var interaction = MinigameInteractions.JoinMinigameQueue.CODEC.decode(doc(
|
||||||
|
"MinigameId", "skywars",
|
||||||
|
"MapId", "Mesa",
|
||||||
|
"VariantId", "Doubles"
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("skywars", field(interaction, "minigameId"));
|
||||||
|
assertEquals("Mesa", field(interaction, "mapId"));
|
||||||
|
assertEquals("Doubles", field(interaction, "variantId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodesModifyScoreInteractionFields() throws Exception {
|
||||||
|
var interaction = MinigameInteractions.ModifyScore.CODEC.decode(doc(
|
||||||
|
"MinigameId", "arena",
|
||||||
|
"TargetType", "Team",
|
||||||
|
"TargetSource", "TagTeamId",
|
||||||
|
"Operation", "Set",
|
||||||
|
"Amount", 42
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("arena", field(interaction, "minigameId"));
|
||||||
|
assertEquals(ModifyScoreEffect.TargetType.TEAM, field(interaction, "targetType"));
|
||||||
|
assertEquals(ModifyScoreEffect.TargetSource.TAG_TEAM_ID, field(interaction, "targetSource"));
|
||||||
|
assertEquals(ModifyScoreEffect.Operation.SET, field(interaction, "operation"));
|
||||||
|
assertEquals(42, field(interaction, "amount"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodesPlayerStatusInteractionFields() throws Exception {
|
||||||
|
var interaction = MinigameInteractions.SetPlayerStatus.CODEC.decode(doc(
|
||||||
|
"MinigameId", "ctf",
|
||||||
|
"PlayerId", "player-1",
|
||||||
|
"Status", "Spectator"
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("ctf", field(interaction, "minigameId"));
|
||||||
|
assertEquals("player-1", field(interaction, "playerId"));
|
||||||
|
assertEquals(PlayerStatus.SPECTATOR, field(interaction, "status"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decodesQueueUiInteractionBaseFields() throws Exception {
|
||||||
|
var interaction = MinigameInteractions.OpenMinigameQueueUI.CODEC.decode(doc(
|
||||||
|
"MinigameId", "parkour",
|
||||||
|
"MapId", "forest"
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("parkour", field(interaction, "minigameId"));
|
||||||
|
assertEquals("forest", field(interaction, "mapId"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BsonDocument doc(Object... values) {
|
||||||
|
BsonDocument document = new BsonDocument();
|
||||||
|
for (int i = 0; i < values.length; i += 2) {
|
||||||
|
document.put((String) values[i], bson(values[i + 1]));
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BsonValue bson(Object value) {
|
||||||
|
if (value instanceof String string) {
|
||||||
|
return new BsonString(string);
|
||||||
|
}
|
||||||
|
if (value instanceof Integer integer) {
|
||||||
|
return new BsonInt32(integer);
|
||||||
|
}
|
||||||
|
if (value instanceof Boolean bool) {
|
||||||
|
return new BsonBoolean(bool);
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unsupported BSON test value: " + value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Object field(Object target, String name) throws Exception {
|
||||||
|
Class<?> current = target.getClass();
|
||||||
|
while (current != null) {
|
||||||
|
try {
|
||||||
|
Field field = current.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return field.get(target);
|
||||||
|
} catch (NoSuchFieldException ignored) {
|
||||||
|
current = current.getSuperclass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new NoSuchFieldException(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.model.MinigameMapCandidate;
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||||
|
import net.kewwbec.minigames.service.MinigameQueueService.JoinResult;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
@@ -245,6 +246,15 @@ final class MinigameQueueServiceTest {
|
|||||||
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
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
|
@Override
|
||||||
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user