Compare commits
7 Commits
49bbd2b871
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7394e8f874 | |||
| e992f18b8e | |||
| ca11bbd4c0 | |||
| cefe008cae | |||
| 511d0e8d4b | |||
| ff2e46a0d2 | |||
| 690d818fe9 |
@@ -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
|
||||||
@@ -49,14 +51,16 @@ or via the asset editor.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Or create one from the server:
|
Or create one from the server (saved into the named asset pack, so it survives restarts):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/minigame create My_Minigame "My Minigame"
|
/minigame create MyAssetPack My_Minigame "My Minigame"
|
||||||
/minigame enable My_Minigame
|
/minigame enable My_Minigame
|
||||||
/minigame start My_Minigame
|
/minigame start My_Minigame
|
||||||
```
|
```
|
||||||
TODO make this into its own UI in game.
|
|
||||||
|
Note: a game can only start once a trigger volume with a `SetMinigamePhase(ACTIVE)`
|
||||||
|
effect exists for it — see `docs/architecture.md` (Lifecycle Contract).
|
||||||
|
|
||||||
|
|
||||||
## Trigger Volume Logic
|
## Trigger Volume Logic
|
||||||
@@ -65,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`
|
||||||
@@ -83,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`
|
||||||
@@ -110,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/
|
||||||
|
|||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"Version": 4,
|
|
||||||
"ServerName": "Hytale Server",
|
|
||||||
"MOTD": "",
|
|
||||||
"Password": "",
|
|
||||||
"MaxPlayers": 100,
|
|
||||||
"MaxViewRadius": 32,
|
|
||||||
"Defaults": {
|
|
||||||
"World": "default",
|
|
||||||
"GameMode": "Adventure"
|
|
||||||
},
|
|
||||||
"ConnectionTimeouts": {},
|
|
||||||
"RateLimit": {},
|
|
||||||
"Modules": {},
|
|
||||||
"LogLevels": {},
|
|
||||||
"Mods": {},
|
|
||||||
"DisplayTmpTagsInStrings": false,
|
|
||||||
"PlayerStorage": {
|
|
||||||
"Type": "Hytale"
|
|
||||||
},
|
|
||||||
"Update": {},
|
|
||||||
"Backup": {},
|
|
||||||
"WorldMap": {}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ Each capture zone uses:
|
|||||||
|
|
||||||
- A **TICK** volume type so it fires continuously while players are standing inside it.
|
- A **TICK** volume type so it fires continuously while players are standing inside it.
|
||||||
- A `CurrentRound` condition that passes only when the correct round number is active.
|
- A `CurrentRound` condition that passes only when the correct round number is active.
|
||||||
- A `ModifyPlayerScore` effect that adds 1 point per trigger fire.
|
- A `ModifyScore` effect that adds 1 point per trigger fire.
|
||||||
|
|
||||||
The tick rate on the volume controls how often points are awarded. Set it to **1 second** in the editor so players earn 1 point per second. If the tick fires every 0.5 seconds, players would earn 2 points per second : adjust the amount or tick rate to your preference.
|
The tick rate on the volume controls how often points are awarded. Set it to **1 second** in the editor so players earn 1 point per second. If the tick fires every 0.5 seconds, players would earn 2 points per second : adjust the amount or tick rate to your preference.
|
||||||
leaving it at zero would be everytick its fired, Giving 30 points
|
leaving it at zero would be everytick its fired, Giving 30 points
|
||||||
@@ -42,7 +42,7 @@ Place this volume on Hilltop A, the first round's scoring area. Size it so that
|
|||||||
|
|
||||||
| Effect | Fields |
|
| Effect | Fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
|
| `ModifyScore` | `MinigameId = King_Of_The_Hill`, `TargetType = PLAYER`, `TargetSource = TRIGGERING_PLAYER`, `Operation = ADD`, `Amount = 1` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ Place this volume on Hilltop B. Same setup as Zone A with only the round number
|
|||||||
|
|
||||||
| Effect | Fields |
|
| Effect | Fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
|
| `ModifyScore` | `MinigameId = King_Of_The_Hill`, `TargetType = PLAYER`, `TargetSource = TRIGGERING_PLAYER`, `Operation = ADD`, `Amount = 1` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Place this volume on Hilltop C.
|
|||||||
|
|
||||||
| Effect | Fields |
|
| Effect | Fields |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ModifyPlayerScore` | `MinigameId = King_Of_The_Hill`, `Operation = ADD`, `Amount = 1` |
|
| `ModifyScore` | `MinigameId = King_Of_The_Hill`, `TargetType = PLAYER`, `TargetSource = TRIGGERING_PLAYER`, `Operation = ADD`, `Amount = 1` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ On Tag Added trigger
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Multiple players on the hill at once** : each player in the volume gets the `ModifyPlayerScore` effect independently. If two players stand on Hilltop A during Round 1, both earn +1/second. This is intentional for KOTH : fighting over the hill is part of the game.
|
**Multiple players on the hill at once** : each player in the volume gets the `ModifyScore` effect independently. If two players stand on Hilltop A during Round 1, both earn +1/second. This is intentional for KOTH : fighting over the hill is part of the game.
|
||||||
|
|
||||||
**Zone size** : make your capture zones intentionally tight so there is real competition for the spot. A zone that is too large removes the "king of the hill" tension.
|
**Zone size** : make your capture zones intentionally tight so there is real competition for the spot. A zone that is too large removes the "king of the hill" tension.
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ All volumes and the definition file in one place. Use this as a checklist when s
|
|||||||
| 2 | Queue Pad | Enter | `MinigameId=King_Of_The_Hill` | — | `JoinMinigameQueue` → `StartQueuedMinigame` |
|
| 2 | Queue Pad | Enter | `MinigameId=King_Of_The_Hill` | — | `JoinMinigameQueue` → `StartQueuedMinigame` |
|
||||||
| 3 | Starting Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=STARTING` | — | `SetMinigamePhase ACTIVE` |
|
| 3 | Starting Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=STARTING` | — | `SetMinigamePhase ACTIVE` |
|
||||||
| 4 | Spawn Zone | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `MinigamePhase=ACTIVE` | `SetPlayerStatus ACTIVE` → `SetSpawnPoint` |
|
| 4 | Spawn Zone | Enter | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `MinigamePhase=ACTIVE` | `SetPlayerStatus ACTIVE` → `SetSpawnPoint` |
|
||||||
| 5 | Capture Zone A | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 1` | `ModifyPlayerScore ADD 1` |
|
| 5 | Capture Zone A | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 1` | `ModifyScore PLAYER TRIGGERING_PLAYER ADD 1` |
|
||||||
| 6 | Capture Zone B | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 2` | `ModifyPlayerScore ADD 1` |
|
| 6 | Capture Zone B | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 2` | `ModifyScore PLAYER TRIGGERING_PLAYER ADD 1` |
|
||||||
| 7 | Capture Zone C | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 3` | `ModifyPlayerScore ADD 1` |
|
| 7 | Capture Zone C | TICK (1s) | `MinigameId=King_Of_The_Hill` `MapId=Hillside` | `PlayerInMinigame` + `CurrentRound=EQUAL 3` | `ModifyScore PLAYER TRIGGERING_PLAYER ADD 1` |
|
||||||
| 8 | Round 2 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=2` | — | `SendMinigameMessage round_2_start ALL` |
|
| 8 | Round 2 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=2` | — | `SendMinigameMessage round_2_start ALL` |
|
||||||
| 9 | Round 3 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=3` | — | `SendMinigameMessage round_3_start ALL` |
|
| 9 | Round 3 Signal | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalRoundStart=3` | — | `SendMinigameMessage round_3_start ALL` |
|
||||||
| 10 | End Announcement | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=ENDING` | — | `SendMinigameMessage game_over ALL` *(optional)* |
|
| 10 | End Announcement | Tag Added | `MinigameId=King_Of_The_Hill` `MapId=Hillside` `SignalPhaseStart=ENDING` | — | `SendMinigameMessage game_over ALL` *(optional)* |
|
||||||
@@ -115,7 +115,7 @@ Conditions:
|
|||||||
CurrentRound MinigameId=King_Of_The_Hill Compare=EQUAL Round=1
|
CurrentRound MinigameId=King_Of_The_Hill Compare=EQUAL Round=1
|
||||||
|
|
||||||
Effects:
|
Effects:
|
||||||
ModifyPlayerScore MinigameId=King_Of_The_Hill Operation=ADD Amount=1
|
ModifyScore MinigameId=King_Of_The_Hill TargetType=PLAYER TargetSource=TRIGGERING_PLAYER Operation=ADD Amount=1
|
||||||
```
|
```
|
||||||
|
|
||||||
Change `Round=1` to `Round=2` and `Round=3` for Zones B and C.
|
Change `Round=1` to `Round=2` and `Round=3` for Zones B and C.
|
||||||
|
|||||||
+51
-3
@@ -49,6 +49,55 @@ Runtime state includes:
|
|||||||
|
|
||||||
`DefaultMinigameService` handles definition-level actions and delegates live actions to the runtime service.
|
`DefaultMinigameService` handles definition-level actions and delegates live actions to the runtime service.
|
||||||
|
|
||||||
|
## Lifecycle Contract
|
||||||
|
|
||||||
|
The built-in lifecycle is intentionally minimal — most progression is **volume-driven**
|
||||||
|
so map designers control exactly when things happen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
WAITING_FOR_PLAYERS set automatically on start; countdown runs (CountdownSeconds)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
STARTING set automatically when the countdown finishes
|
||||||
|
|
|
||||||
|
v
|
||||||
|
ACTIVE set by a volume: SetMinigamePhase(ACTIVE) <-- REQUIRED
|
||||||
|
|
|
||||||
|
v (rounds: a SetRound effect starts round 1 and the $round timer)
|
||||||
|
OVERTIME / SUDDEN_DEATH automatic after the final round, when enabled (timed)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
ENDING -> RESETTING via EndMinigame effect, /minigame stop, or round completion
|
||||||
|
```
|
||||||
|
|
||||||
|
Hard rules:
|
||||||
|
|
||||||
|
- **A game refuses to start when no trigger volume carries a `SetMinigamePhase(ACTIVE)`
|
||||||
|
effect for its minigame/map/variant ids.** Queued players are told why. This catches
|
||||||
|
maps whose lifecycle wiring is incomplete instead of leaving the game stuck in `STARTING`.
|
||||||
|
- The round system only runs after a `SetRound` effect fires (typically from the
|
||||||
|
`SignalPhaseStart=ACTIVE` volume). `TotalRounds`/`RoundLengthSeconds` alone do nothing.
|
||||||
|
- `SetMinigamePhase(ENDING)` and `(RESETTING)` route through the full end/reset lifecycle
|
||||||
|
(cleanup, rewards, inventory restore) — they are equivalent to `EndMinigame`/`ResetMinigame`.
|
||||||
|
- With `MapSelectionMode: VOTE` and more than one voteable map, reaching `MinPlayers`
|
||||||
|
opens a 30-second vote window. The game starts when every queued player has voted or
|
||||||
|
the window expires.
|
||||||
|
|
||||||
|
## Threading Model
|
||||||
|
|
||||||
|
Each Hytale world ticks on its own thread. The rules this module follows:
|
||||||
|
|
||||||
|
- Every `MinigameRuntime` records a **home world** at start (adopted on first pregame
|
||||||
|
tick if unknown). All mutation of runtime state happens on that world's thread.
|
||||||
|
- Ticking systems (pregame, HUD, queue UI, menu) run per world and only touch runtimes
|
||||||
|
homed in their world.
|
||||||
|
- The signal-service scheduler thread never mutates state directly — it hops onto the
|
||||||
|
home world via `world.execute(...)`.
|
||||||
|
- Queue sessions are shared across worlds and synchronized internally.
|
||||||
|
- Adapter calls that need a result (`saveInventory`) only run on the player's own world
|
||||||
|
thread; cross-world void calls (give item, teleport, restore) are forwarded with
|
||||||
|
`world.execute` and never block the calling thread.
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
`MinigameEvent` implements Hytale's `IEvent<String>`.
|
`MinigameEvent` implements Hytale's `IEvent<String>`.
|
||||||
@@ -94,10 +143,9 @@ Current effect type IDs:
|
|||||||
- `ResetMinigame`
|
- `ResetMinigame`
|
||||||
- `SetMinigamePhase`
|
- `SetMinigamePhase`
|
||||||
- `ResetScores`
|
- `ResetScores`
|
||||||
- `ModifyPlayerScore`
|
- `ModifyScore`
|
||||||
- `ModifyTeamScore`
|
|
||||||
- `ModifyCounter`
|
- `ModifyCounter`
|
||||||
- `ModifyPlayerLives`
|
- `ModifyLives`
|
||||||
- `SetPlayerStatus`
|
- `SetPlayerStatus`
|
||||||
- `JoinMinigameQueue`
|
- `JoinMinigameQueue`
|
||||||
- `LeaveMinigameQueue`
|
- `LeaveMinigameQueue`
|
||||||
|
|||||||
+90
-36
@@ -8,62 +8,67 @@ All commands are subcommands of `/minigame`.
|
|||||||
|
|
||||||
Lists registered minigames and basic runtime state.
|
Lists registered minigames and basic runtime state.
|
||||||
|
|
||||||
### `/minigame create <id> <name>`
|
### `/minigame create <pack> <id> <name>`
|
||||||
|
|
||||||
Creates a new minigame definition with default settings.
|
Creates a new minigame definition with default settings and saves it **into the named
|
||||||
|
asset pack** (`<pack>/Server/Minigames/<id>.json`). The pack must exist and be writable
|
||||||
|
(not a read-only/zipped pack). Because the definition lives in an asset pack, it is
|
||||||
|
reloaded by the normal asset scan on every server start.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/minigame create Beach_Race "Beach Race"
|
/minigame create KingOfHillAssetPack Beach_Race "Beach Race"
|
||||||
```
|
```
|
||||||
|
|
||||||
Definitions created by command start disabled. Enable them when the asset is configured.
|
Ids may only contain letters, numbers, `_` and `-`.
|
||||||
|
|
||||||
### `/minigame info <id>`
|
### `/minigame info <id>`
|
||||||
|
|
||||||
Shows the loaded definition and runtime status for a minigame.
|
Shows the loaded definition and runtime status for a minigame.
|
||||||
|
|
||||||
### `/minigame edit <id> <field> <value>`
|
### `/minigame edit <id> <property> <value>`
|
||||||
|
|
||||||
Edits a single definition field and saves the definition.
|
Edits a simple definition property and saves it back into its asset pack.
|
||||||
|
Supported properties: `name`, `description`, `enabled`. Use the Hytale asset
|
||||||
|
editor for everything else.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/minigame edit Beach_Race MaxPlayers 16
|
/minigame edit Beach_Race name Beach Race Deluxe
|
||||||
/minigame edit Beach_Race RoundLengthSeconds 600
|
/minigame edit Beach_Race enabled true
|
||||||
```
|
```
|
||||||
|
|
||||||
### `/minigame enable <id>`
|
### `/minigame enable <id>` / `/minigame disable <id>`
|
||||||
|
|
||||||
Sets `Enabled = true`.
|
Sets `Enabled` and persists the definition to its asset pack. Disabling prevents new
|
||||||
|
queue joins and starts but does not stop an already running runtime.
|
||||||
### `/minigame disable <id>`
|
|
||||||
|
|
||||||
Sets `Enabled = false`. This prevents new starts but does not necessarily stop an already running runtime.
|
|
||||||
|
|
||||||
### `/minigame validate <id>`
|
### `/minigame validate <id>`
|
||||||
|
|
||||||
Runs definition validation. Current validation focuses on definition fields. The old custom-volume requirement for `MinigameAreaVolume` no longer applies.
|
Runs definition validation (player counts, round length, countdown, overtime/sudden-death
|
||||||
|
timer settings).
|
||||||
|
|
||||||
### `/minigame delete <id>`
|
### `/minigame delete <id>`
|
||||||
|
|
||||||
Deletes the minigame definition.
|
Ends any running runtimes, removes the definition from the asset store, and deletes its
|
||||||
|
file from its asset pack. Definitions in read-only packs cannot be deleted.
|
||||||
### `/minigame export <id>`
|
|
||||||
|
|
||||||
Exports the current definition JSON.
|
|
||||||
|
|
||||||
### `/minigame import <file>`
|
|
||||||
|
|
||||||
Imports a definition from a JSON file path relative to the data directory.
|
|
||||||
|
|
||||||
## Runtime Management
|
## Runtime Management
|
||||||
|
|
||||||
### `/minigame start <id>`
|
### `/minigame start <id>`
|
||||||
|
|
||||||
Starts a queued runtime for the given minigame. The selected map comes from `MapSelectionMode`: highest vote for `VOTE`, random discovered map for `RANDOM`.
|
Starts a queued runtime for the given minigame as an **admin start**: the min-player
|
||||||
|
pregame check is skipped, so the game runs even with an empty or short queue.
|
||||||
|
The selected map comes from `MapSelectionMode`: highest vote for `VOTE`, random
|
||||||
|
discovered map for `RANDOM`.
|
||||||
|
|
||||||
### `/minigame stop <id>`
|
A start is refused (with a message) when:
|
||||||
|
|
||||||
Stops all active runtimes for the minigame ID. An optional greedy `reason` argument can override the default admin-stop reason.
|
- the definition has a `WorldId` and you are in a different world, or
|
||||||
|
- no trigger volume sets the game's phase to `ACTIVE` (see *Lifecycle contract*
|
||||||
|
in `architecture.md`).
|
||||||
|
|
||||||
|
### `/minigame stop <id> [reason]`
|
||||||
|
|
||||||
|
Stops all active runtimes for the minigame ID.
|
||||||
|
|
||||||
### `/minigame reset <id>`
|
### `/minigame reset <id>`
|
||||||
|
|
||||||
@@ -73,28 +78,77 @@ Resets all active runtimes for the minigame ID.
|
|||||||
|
|
||||||
### `/minigame maps <id>`
|
### `/minigame maps <id>`
|
||||||
|
|
||||||
Lists maps discovered from trigger volumes in the sender's current world. Run this command as a player in the world containing the tagged volumes.
|
Lists maps discovered from trigger volumes in the sender's current world. Run this command
|
||||||
|
as a player in the world containing the tagged volumes.
|
||||||
|
|
||||||
### `/minigame vote <id> <mapId>`
|
### `/minigame vote <id> <mapId>`
|
||||||
|
|
||||||
Casts or updates the sender's vote in the minigame waiting session.
|
Casts or updates the sender's vote. Only players who are in the queue may vote.
|
||||||
|
|
||||||
## Player Commands
|
## Player Commands
|
||||||
|
|
||||||
### `/minigame join <id> [player]`
|
### `/minigame join <id> [player]`
|
||||||
|
|
||||||
Adds the command sender, or the optional player target, to the minigame waiting queue.
|
Adds the command sender, or the optional player target, to the minigame queue.
|
||||||
|
Fails when the queue is full (`MaxPlayers`) or the minigame is disabled.
|
||||||
|
|
||||||
### `/minigame leave [player]`
|
### `/minigame leave [player]`
|
||||||
|
|
||||||
Currently registered but not implemented. Use the `LeaveMinigameQueue` trigger effect for in-world queue leaving until the command is wired.
|
Removes the player from every queue and from their current game. Leaving a game restores
|
||||||
|
the player's saved inventory; if nobody is left in the runtime, it ends.
|
||||||
|
|
||||||
### `/minigame spectate <id>`
|
### `/minigame spectate <id> [player]`
|
||||||
|
|
||||||
Currently registered but not implemented. The command validates that the minigame exists and has a running runtime, then returns a not-implemented message.
|
Joins a running game as a spectator (requires `AllowSpectators: true` in the definition)
|
||||||
|
and teleports the spectator to the game's MainArena volume when one is visible from
|
||||||
|
their world. Spectators are excluded from rankings and the scoreboard.
|
||||||
|
|
||||||
## Volume Commands
|
## Volume Workflow
|
||||||
|
|
||||||
The old custom minigame volume system was removed. Use Hytale's trigger-volume editor for real volume placement and attach the minigame trigger effects and conditions there.
|
The old custom minigame volume system was removed. Use Hytale's trigger-volume editor for
|
||||||
|
volume placement and attach the minigame trigger effects and conditions there.
|
||||||
|
`/triggervolume clone` and `/triggervolume clonegroup` (injected by this plugin) help
|
||||||
|
duplicate configured volumes between maps.
|
||||||
|
|
||||||
`/minigame volume create <type>` currently remains as a command stub for future Hytale trigger-volume integration. It should not be used as the primary way to define arenas.
|
### `/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.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Current runtime events emitted by the service layer:
|
|||||||
| `on_sudden_death_start` | The final round ends and `SuddenDeathEnabled=true`. Phase is set to `SUDDEN_DEATH`. Fired before `on_game_rounds_complete`. |
|
| `on_sudden_death_start` | The final round ends and `SuddenDeathEnabled=true`. Phase is set to `SUDDEN_DEATH`. Fired before `on_game_rounds_complete`. |
|
||||||
| `on_game_rounds_complete` | The final configured round ends (after any overtime/sudden-death events). |
|
| `on_game_rounds_complete` | The final configured round ends (after any overtime/sudden-death events). |
|
||||||
| `on_timer_expired` | A named signal timer expires. The internal `$round` timer is consumed by runtime round progression and is not re-dispatched. |
|
| `on_timer_expired` | A named signal timer expires. The internal `$round` timer is consumed by runtime round progression and is not re-dispatched. |
|
||||||
| `on_player_eliminated` | `SetPlayerStatus` sets `ELIMINATED`, or `ModifyPlayerLives` reduces lives to zero. |
|
| `on_player_eliminated` | `SetPlayerStatus` sets `ELIMINATED`, or `ModifyLives` reduces lives to zero. |
|
||||||
| `on_objective_complete` | An objective status becomes `COMPLETE` or objective progress reaches completion. |
|
| `on_objective_complete` | An objective status becomes `COMPLETE` or objective progress reaches completion. |
|
||||||
| `on_kill_score` | The kill scoring system awards points for a configured kill. |
|
| `on_kill_score` | The kill scoring system awards points for a configured kill. |
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ All minigame conditions share a `MinigameId` field. In the editor this is regist
|
|||||||
|
|
||||||
## Effects
|
## Effects
|
||||||
|
|
||||||
All minigame effects that operate on a runtime include a `MinigameId` field. In the editor this is registered as an asset picker backed by `MinigameDefinition` assets.
|
All minigame effects that operate on a runtime include a `MinigameId` field. In the editor this is registered as an asset picker backed by `MinigameDefinition` assets. Item and block fields are likewise registered as the editor's built-in pickers: `SpawnItem`'s `ItemId` uses the `Item` picker and `PlaceBlocks`'s `BlockId` uses the `BlockType` picker (the same menus the vanilla `GiveItem` / `PlaceBlock` effects use), so designers select from the live asset list instead of typing raw ids.
|
||||||
|
|
||||||
| Type ID | Fields | Description |
|
| Type ID | Fields | Description |
|
||||||
|---------|--------|-------------|
|
|---------|--------|-------------|
|
||||||
@@ -69,7 +69,7 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
|
|||||||
| `LeaveMinigameQueue` | `MinigameId` | Removes the triggering player from the minigame waiting session and clears their vote. |
|
| `LeaveMinigameQueue` | `MinigameId` | Removes the triggering player from the minigame waiting session and clears their vote. |
|
||||||
| `VoteForMap` | `MinigameId`, optional `MapId` | Votes for a map in the waiting session. If `MapId` is blank, the triggering volume's `MapId` tag is used. |
|
| `VoteForMap` | `MinigameId`, optional `MapId` | Votes for a map in the waiting session. If `MapId` is blank, the triggering volume's `MapId` tag is used. |
|
||||||
| `StartQueuedMinigame` | `MinigameId` | Starts a queued match using `MapSelectionMode` and discovered map tags. |
|
| `StartQueuedMinigame` | `MinigameId` | Starts a queued match using `MapSelectionMode` and discovered map tags. |
|
||||||
| `SpawnItem` | optional `MinigameId`, `ItemId`, optional `Amount`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ` | Spawns an item entity on the ground at the trigger volume position. When attached to a `TICK` volume, it respawns after the previous item is picked up or despawns and the delay has elapsed. |
|
| `SpawnItem` | optional `MinigameId`, `ItemId`, optional `Amount`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ`, optional `IgnoreRespawnTimer` | Spawns an item entity on the ground at the trigger volume position. When attached to a `TICK` volume, it respawns after the previous item is picked up or despawns and the delay has elapsed. When `IgnoreRespawnTimer` is `true`, it ignores whether the previous item still exists and spawns a fresh copy every `RespawnDelaySeconds` (or every tick when the delay is `0`). |
|
||||||
| `SpawnNpc` | optional `MinigameId`, `RoleId`, optional `Count`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ`, `Yaw`, `Pitch`, `Roll`, `SpreadRadius` | Spawns one or more NPCs at the trigger volume position. Spawned NPCs are tracked as temporary runtime entities and can respawn after all active NPCs for the spawn key are gone. |
|
| `SpawnNpc` | optional `MinigameId`, `RoleId`, optional `Count`, `RespawnDelaySeconds`, `SpawnKey`, `OffsetX`, `OffsetY`, `OffsetZ`, `Yaw`, `Pitch`, `Roll`, `SpreadRadius` | Spawns one or more NPCs at the trigger volume position. Spawned NPCs are tracked as temporary runtime entities and can respawn after all active NPCs for the spawn key are gone. |
|
||||||
| `MountPlayer` | optional `MinigameId`, `PlayerId`, `NpcId`, `NpcRoleId`, `MaxDistance`, `AnchorX`, `AnchorY`, `AnchorZ`, `MovementConfig`, `EmptyRoleId`, optional `PreventManualDismount` | Mounts the triggering or configured player onto the nearest matching tracked NPC. When `PreventManualDismount` is `true`, the player cannot dismount manually until a `DismountPlayer` effect runs. |
|
| `MountPlayer` | optional `MinigameId`, `PlayerId`, `NpcId`, `NpcRoleId`, `MaxDistance`, `AnchorX`, `AnchorY`, `AnchorZ`, `MovementConfig`, `EmptyRoleId`, optional `PreventManualDismount` | Mounts the triggering or configured player onto the nearest matching tracked NPC. When `PreventManualDismount` is `true`, the player cannot dismount manually until a `DismountPlayer` effect runs. |
|
||||||
| `DismountPlayer` | optional `MinigameId`, optional `PlayerId`, optional `NpcRoleId`, optional `RemoveNpc` | Dismounts the player from their NPC mount. `RemoveNpc` defaults to `true` and removes the mount entity from the world. Clears any manual-dismount lock set by `MountPlayer`. |
|
| `DismountPlayer` | optional `MinigameId`, optional `PlayerId`, optional `NpcRoleId`, optional `RemoveNpc` | Dismounts the player from their NPC mount. `RemoveNpc` defaults to `true` and removes the mount entity from the world. Clears any manual-dismount lock set by `MountPlayer`. |
|
||||||
@@ -79,17 +79,18 @@ All minigame effects that operate on a runtime include a `MinigameId` field. In
|
|||||||
| `ResetMinigame` | `MinigameId` | Resets the selected minigame runtime. |
|
| `ResetMinigame` | `MinigameId` | Resets the selected minigame runtime. |
|
||||||
| `SetMinigamePhase` | `MinigameId`, `Phase` | Sets the runtime phase directly. |
|
| `SetMinigamePhase` | `MinigameId`, `Phase` | Sets the runtime phase directly. |
|
||||||
| `ResetScores` | `MinigameId` | Resets all player and team scores for the runtime. |
|
| `ResetScores` | `MinigameId` | Resets all player and team scores for the runtime. |
|
||||||
| `ModifyPlayerScore` | `MinigameId`, optional `PlayerId`, `Operation`, `Amount` | Sets, adds, or subtracts a player's score. |
|
| `ModifyScore` | `MinigameId`, `TargetType`, `TargetSource`, `Operation`, `Amount` | Sets, adds, or subtracts player or team score. `TargetType=TEAM` with `TargetSource=TAG_TEAM_ID` uses the volume's `TeamId` tag. |
|
||||||
| `ModifyTeamScore` | `MinigameId`, `TeamId`, `Operation`, `Amount` | Sets, adds, or subtracts a team's score. |
|
|
||||||
| `ModifyCounter` | `MinigameId`, `CounterId`, `Operation`, `Amount` | Sets, adds, or subtracts a named runtime counter. |
|
| `ModifyCounter` | `MinigameId`, `CounterId`, `Operation`, `Amount` | Sets, adds, or subtracts a named runtime counter. |
|
||||||
| `ModifyPlayerLives` | `MinigameId`, optional `PlayerId`, `Operation`, `Amount` | Sets, adds, or subtracts a player's remaining lives. |
|
| `ModifyLives` | `MinigameId`, `TargetType`, `TargetSource`, `Operation`, `Amount` | Sets, adds, or subtracts one player's lives or all players on a resolved team. `TargetType=TEAM` with `TargetSource=TAG_TEAM_ID` uses the volume's `TeamId` tag. |
|
||||||
| `SetPlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Sets a player's minigame status. Setting `ACTIVE` triggers the activation lifecycle: saves inventory (if `SavePlayerInventory`), gives loadout or start items, and applies `RequiredGameMode`. |
|
| `SetPlayerStatus` | `MinigameId`, optional `PlayerId`, `Status` | Sets a player's minigame status. Setting `ACTIVE` triggers the activation lifecycle: saves inventory (if `SavePlayerInventory`), gives loadout or start items, and applies `RequiredGameMode`. |
|
||||||
| `SetPlayerLoadout` | `MinigameId`, optional `PlayerId`, `LoadoutId` | Assigns a named loadout to a player. The loadout must exist in `StartLoadouts`. Takes effect the next time the player becomes `ACTIVE`. Pass an empty `LoadoutId` to clear the assignment. |
|
| `SetPlayerLoadout` | `MinigameId`, optional `PlayerId`, `LoadoutId` | Assigns a named loadout to a player. The loadout must exist in `StartLoadouts`. Takes effect the next time the player becomes `ACTIVE`. Pass an empty `LoadoutId` to clear the assignment. |
|
||||||
| `SetRound` | `MinigameId`, `Operation`, `Amount` | Sets, adds, or subtracts the runtime round number and dispatches `on_round_start`. |
|
| `SetRound` | `MinigameId`, `Operation`, `Amount` | Sets, adds, or subtracts the runtime round number and dispatches `on_round_start`. |
|
||||||
| `StartTimer` | `MinigameId`, `TimerName`, `DurationSeconds` | Starts a named runtime timer and signal timer. |
|
| `StartTimer` | `MinigameId`, `TimerName`, `DurationSeconds` | Starts a named runtime timer and signal timer. |
|
||||||
| `StopTimer` | `MinigameId`, `TimerName` | Stops a named runtime timer and cancels the signal timer. |
|
| `StopTimer` | `MinigameId`, `TimerName` | Stops a named runtime timer and cancels the signal timer. |
|
||||||
| `SetSpawnPoint` | `MinigameId`, optional `PlayerId`, `DestinationId` | Stores a player's respawn destination/checkpoint. |
|
| `SetSpawnPoint` | `MinigameId`, optional `PlayerId`, `X`, `Y`, `Z` | Stores a player's respawn destination/checkpoint. |
|
||||||
| `RespawnPlayer` | `MinigameId`, optional `PlayerId`, optional `DestinationId` | Respawns a player using explicit destination, checkpoint, then team spawn fallback. |
|
| `RespawnPlayer` | `MinigameId`, optional `PlayerId`, optional `DestinationId` | Respawns a player using explicit destination, checkpoint, then team spawn fallback. |
|
||||||
|
| `RespawnAllPlayers` | `MinigameId`, optional `DestinationId`, optional `ClearCheckpoints`, optional `ActiveOnly`, optional `ReactivateEliminated` | Respawns every player in the runtime in one pass (round reset). `ClearCheckpoints` wipes stored checkpoints first so players go to their team spawn / start instead of their last checkpoint. `DestinationId` overrides every player's spawn for this respawn (e.g. a shared race start). `ActiveOnly` (default `true`) skips spectators and eliminated players. `ReactivateEliminated` returns eliminated players to `ACTIVE` before respawning them. |
|
||||||
|
| `SwapTeams` | `MinigameId`, optional `Rotation`, optional `ClearCheckpoints`, optional `Respawn` | Rotates players between teams for alternating-sides games. `Rotation` (default `1`) shifts each team's members forward in the id-sorted team list; `1` is a straight swap for two teams. `ClearCheckpoints` (default `true`) wipes checkpoints so respawn resolves the new side's spawn. `Respawn` (default `true`) teleports reassigned `ACTIVE` players to their new team spawn. Needs at least two teams. |
|
||||||
| `SendMinigameMessage` | `MinigameId`, `MessageKey`, `Target` | Sends a translated message to `PLAYER`, `TEAM`, or `ALL`. |
|
| `SendMinigameMessage` | `MinigameId`, `MessageKey`, `Target` | Sends a translated message to `PLAYER`, `TEAM`, or `ALL`. |
|
||||||
| `AssignTeam` | `MinigameId`, optional `PlayerId`, `TeamId`, optional `BalanceTeams` | Assigns a player to a team in the runtime. If `BalanceTeams` is true, evenly distributes all runtime players across existing teams or auto-creates `team1..teamN` using `TeamCount`, or `PlayersPerTeam` when `TeamCount` is `0`. |
|
| `AssignTeam` | `MinigameId`, optional `PlayerId`, `TeamId`, optional `BalanceTeams` | Assigns a player to a team in the runtime. If `BalanceTeams` is true, evenly distributes all runtime players across existing teams or auto-creates `team1..teamN` using `TeamCount`, or `PlayersPerTeam` when `TeamCount` is `0`. |
|
||||||
| `SaveInventory` | `MinigameId`, optional `PlayerId` | Saves the player's current inventory into runtime state. |
|
| `SaveInventory` | `MinigameId`, optional `PlayerId` | Saves the player's current inventory into runtime state. |
|
||||||
@@ -124,10 +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. `DestinationId` and `SetSpawnPoint` checkpoint values are coordinate strings, such as `100,64,200` or `world_name,100,64,200`.
|
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 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 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
|
||||||
|
|
||||||
@@ -135,6 +141,8 @@ Player deaths during a minigame use the same resolution order automatically. `De
|
|||||||
|
|
||||||
For automatic respawns, attach it to a trigger volume that fires on `TICK`. The effect keeps one active item per volume plus `SpawnKey`; once that item reference is invalid, it waits `RespawnDelaySeconds` before spawning the next copy. If one volume hosts several item spawners, give each effect a different `SpawnKey`.
|
For automatic respawns, attach it to a trigger volume that fires on `TICK`. The effect keeps one active item per volume plus `SpawnKey`; once that item reference is invalid, it waits `RespawnDelaySeconds` before spawning the next copy. If one volume hosts several item spawners, give each effect a different `SpawnKey`.
|
||||||
|
|
||||||
|
Set `IgnoreRespawnTimer=true` to drop the one-active-item rule and instead spawn a fresh copy on a fixed cadence regardless of whether the previous item was picked up or removed. The cadence is `RespawnDelaySeconds` between spawns (or every tick when the delay is `0`). Use this for a steady drip of items rather than a single replaceable pickup — be mindful that uncollected items accumulate in the world.
|
||||||
|
|
||||||
## NPC Spawners
|
## NPC Spawners
|
||||||
|
|
||||||
`SpawnNpc` uses Hytale NPC role ids. `RoleId` is registered as a spawnable NPC role asset picker in the editor. Use `Count` plus `SpreadRadius` for small packs, and use `RespawnDelaySeconds` on a repeatedly firing volume when the encounter should come back after all NPCs for the same `SpawnKey` are gone.
|
`SpawnNpc` uses Hytale NPC role ids. `RoleId` is registered as a spawnable NPC role asset picker in the editor. Use `Count` plus `SpreadRadius` for small packs, and use `RespawnDelaySeconds` on a repeatedly firing volume when the encounter should come back after all NPCs for the same `SpawnKey` are gone.
|
||||||
|
|||||||
+8
-5
@@ -62,10 +62,10 @@ Editor setup:
|
|||||||
|
|
||||||
1. Create a normal Hytale trigger volume around the scoring area.
|
1. Create a normal Hytale trigger volume around the scoring area.
|
||||||
2. Add `PlayerInMinigame` as a condition.
|
2. Add `PlayerInMinigame` as a condition.
|
||||||
3. Add `ModifyPlayerScore` as an effect.
|
3. Add `ModifyScore` as an effect.
|
||||||
4. Set `MinigameId = Hill_Points`, `Operation = ADD`, and `Amount = 1`.
|
4. Set `MinigameId = Hill_Points`, `TargetType = PLAYER`, `TargetSource = TRIGGERING_PLAYER`, `Operation = ADD`, and `Amount = 1`.
|
||||||
|
|
||||||
If the trigger context already contains the player who entered the volume, leave `PlayerId` blank.
|
If the trigger context contains the player who entered the volume, `TargetSource = TRIGGERING_PLAYER` awards that player.
|
||||||
|
|
||||||
## End When Score Reaches a Target
|
## End When Score Reaches a Target
|
||||||
|
|
||||||
@@ -84,15 +84,18 @@ Effect:
|
|||||||
|
|
||||||
## Team Score Zone
|
## Team Score Zone
|
||||||
|
|
||||||
Use `ModifyTeamScore` when the score belongs to a team instead of a player.
|
Use `ModifyScore` with `TargetType=TEAM` when the score belongs to a team instead of a player.
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|
||||||
- `MinigameId`
|
- `MinigameId`
|
||||||
- `TeamId`
|
- `TargetType = TEAM`
|
||||||
|
- `TargetSource = TRIGGERING_TEAM` or `TAG_TEAM_ID`
|
||||||
- `Operation`
|
- `Operation`
|
||||||
- `Amount`
|
- `Amount`
|
||||||
|
|
||||||
|
When using `TargetSource = TAG_TEAM_ID`, add `TeamId=<team>` to the trigger volume tags.
|
||||||
|
|
||||||
Use `AssignTeam` with `BalanceTeams=true` to split all players in the started runtime evenly across teams. If no teams already exist, the effect creates `team1..teamN` from the minigame's `TeamCount`.
|
Use `AssignTeam` with `BalanceTeams=true` to split all players in the started runtime evenly across teams. If no teams already exist, the effect creates `team1..teamN` from the minigame's `TeamCount`.
|
||||||
|
|
||||||
## Counter Gate
|
## Counter Gate
|
||||||
|
|||||||
+12
-10
@@ -17,7 +17,7 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
|||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
| Field | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `WorldId` | String | No | `""` | Optional world identifier. |
|
| `WorldId` | String | No | `""` | When set, the game may only be started in this world; starts from other worlds are refused with a message. |
|
||||||
| `RequiredGameMode` | Enum | No | `Adventure` | Game mode applied to each player when they become active. |
|
| `RequiredGameMode` | Enum | No | `Adventure` | Game mode applied to each player when they become active. |
|
||||||
|
|
||||||
## Players
|
## Players
|
||||||
@@ -27,14 +27,14 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
|||||||
| `MinPlayers` | Integer | No | `1` | Minimum player count. |
|
| `MinPlayers` | Integer | No | `1` | Minimum player count. |
|
||||||
| `MaxPlayers` | Integer | No | `16` | Maximum player count. |
|
| `MaxPlayers` | Integer | No | `16` | Maximum player count. |
|
||||||
| `DefaultPlayerLives` | Integer | No | `0` | Initial lives assigned to each player when a queued runtime starts. |
|
| `DefaultPlayerLives` | Integer | No | `0` | Initial lives assigned to each player when a queued runtime starts. |
|
||||||
| `AllowJoinMidgame` | Boolean | No | `false` | Whether players can join after the game starts. |
|
| `AllowJoinMidgame` | Boolean | No | `false` | When `true`, a player who disconnects mid-game is re-activated on reconnect. When `false`, reconnecting players get their saved inventory back and are dropped from the game. |
|
||||||
| `AllowSpectators` | Boolean | No | `true` | Whether spectators are allowed. |
|
| `AllowSpectators` | Boolean | No | `true` | Whether `/minigame spectate` may join the game as a spectator. |
|
||||||
|
|
||||||
## Teams
|
## Teams
|
||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
| Field | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `TeamMode` | Enum | No | `SOLO` | `SOLO`, `TEAMS`, or `FREE_FOR_ALL`. |
|
| `TeamMode` | Enum | No | `SOLO` | `SOLO`, `TEAMS`, or `FREE_FOR_ALL`. With `TEAMS`, teams are auto-created and balanced when a queued game starts (using `TeamCount` / `PlayersPerTeam`). |
|
||||||
| `TeamCount` | Integer | No | `0` | Explicit number of teams when using `BalanceTeams`. `0` defers to `PlayersPerTeam`. |
|
| `TeamCount` | Integer | No | `0` | Explicit number of teams when using `BalanceTeams`. `0` defers to `PlayersPerTeam`. |
|
||||||
| `PlayersPerTeam` | Integer | No | `0` | When `TeamCount` is `0`, `BalanceTeams` divides total players by this to determine team count. `0` means no automatic splitting. |
|
| `PlayersPerTeam` | Integer | No | `0` | When `TeamCount` is `0`, `BalanceTeams` divides total players by this to determine team count. `0` means no automatic splitting. |
|
||||||
|
|
||||||
@@ -45,8 +45,10 @@ Minigame definitions live in `src/main/resources/Server/Minigames/<Id>.json`. JS
|
|||||||
| `RoundLengthSeconds` | Integer | No | `300` | Active round duration. |
|
| `RoundLengthSeconds` | Integer | No | `300` | Active round duration. |
|
||||||
| `TotalRounds` | Integer | No | `1` | Number of rounds to run. Values less than or equal to `0` allow open-ended round progression. |
|
| `TotalRounds` | Integer | No | `1` | Number of rounds to run. Values less than or equal to `0` allow open-ended round progression. |
|
||||||
| `CountdownSeconds` | Integer | No | `10` | Pre-game countdown duration. |
|
| `CountdownSeconds` | Integer | No | `10` | Pre-game countdown duration. |
|
||||||
| `OvertimeEnabled` | Boolean | No | `false` | When the final round ends, sets the phase to `OVERTIME` and dispatches `on_overtime_start` before `on_game_rounds_complete`. |
|
| `OvertimeEnabled` | Boolean | No | `false` | When the final round ends, enters `OVERTIME` for `OvertimeSeconds` and dispatches `on_overtime_start`. When the overtime timer expires the game either enters sudden death (if enabled) or ends. |
|
||||||
| `SuddenDeathEnabled` | Boolean | No | `false` | When the final round ends (after overtime if also enabled), sets the phase to `SUDDEN_DEATH` and dispatches `on_sudden_death_start`. |
|
| `OvertimeSeconds` | Integer | No | `60` | Overtime duration. Must be positive when overtime is enabled. |
|
||||||
|
| `SuddenDeathEnabled` | Boolean | No | `false` | After the final round (and overtime, if enabled), enters `SUDDEN_DEATH` for `SuddenDeathSeconds` and dispatches `on_sudden_death_start`. The game ends when the timer expires. |
|
||||||
|
| `SuddenDeathSeconds` | Integer | No | `60` | Sudden-death duration. Must be positive when sudden death is enabled. |
|
||||||
|
|
||||||
## Win Condition
|
## Win Condition
|
||||||
|
|
||||||
@@ -72,16 +74,16 @@ Supported values:
|
|||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
| Field | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `AllowPvp` | Boolean | No | `false` | Whether players can damage each other. |
|
| `AllowPvp` | Boolean | No | `false` | Enforced: player-vs-player damage is cancelled for players in this game when `false` (applies when either the attacker or the victim is in a no-PvP game). |
|
||||||
| `AllowBlockBreaking` | Boolean | No | `false` | Whether blocks can be broken. Readable via definition; enforcement relies on `RequiredGameMode` (`Adventure` blocks both break and place by default). |
|
| `AllowBlockBreaking` | Boolean | No | `false` | Enforced: block breaking by players in this game is cancelled when `false`. |
|
||||||
| `AllowBlockPlacing` | Boolean | No | `false` | Whether blocks can be placed. Same enforcement note as above. |
|
| `AllowBlockPlacing` | Boolean | No | `false` | Enforced: block placing by players in this game is cancelled when `false`. |
|
||||||
|
|
||||||
## Lifecycle
|
## Lifecycle
|
||||||
|
|
||||||
| Field | Type | Required | Default | Description |
|
| Field | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `ResetOnEnd` | Boolean | No | `true` | After clearing runtime state at game end, dispatches `on_arena_reset` so trigger volumes can reset world state. |
|
| `ResetOnEnd` | Boolean | No | `true` | After clearing runtime state at game end, dispatches `on_arena_reset` so trigger volumes can reset world state. |
|
||||||
| `SavePlayerInventory` | Boolean | No | `true` | Saves each player's inventory the first time they become `ACTIVE`. Restored on game end if `RestorePlayerInventory` is true. |
|
| `SavePlayerInventory` | Boolean | No | `true` | Saves and then **clears** each player's inventory the first time they become `ACTIVE`. Items are only granted by volume effects (`SetPlayerLoadout`, `SpawnItem`) — nothing is given automatically. Restored on game end if `RestorePlayerInventory` is true, and when a player leaves or disconnects. |
|
||||||
| `RestorePlayerInventory` | Boolean | No | `true` | Restores each player's saved inventory when the game ends. |
|
| `RestorePlayerInventory` | Boolean | No | `true` | Restores each player's saved inventory when the game ends. |
|
||||||
|
|
||||||
## Items
|
## Items
|
||||||
|
|||||||
@@ -11,13 +11,4 @@ Make it in the set spawn point you can type the XYZ for where to spawn the playe
|
|||||||
the double send message name. Need to change the custom one.
|
the double send message name. Need to change the custom one.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Bugs
|
## Bugs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Completed
|
|
||||||
|
|
||||||
- **HUD top-right positioning** — HUD anchor now uses `setRight(8).setTop(8)` and renders in the top-right corner.
|
|
||||||
- **Scoreboard HUD panel** (`ShowScoreboard` in definition) — ranked player list with name + score shown below the standard HUD tiles. Capped at 10 rows. IDs are stable for delta refresh.
|
|
||||||
- **Queue UI system** (`OpenMinigameQueueUI` effect) — three modes: Global (all UIQueueable games), Single (one game's maps), Local (single arena popup). Join auto-attempts game start. Leave queue button shown when already queued. Volume tags: `UIQueue=True` + `UIQueueType=Global|Single|Local` on `MainArena` volumes. Definition flag: `UIQueueable=true`.
|
|
||||||
|
|||||||
+16
-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 |
|
| `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,9 @@ 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.
|
||||||
|
|
||||||
## Signal Tags
|
## Signal Tags
|
||||||
|
|
||||||
|
|||||||
+58
-6
@@ -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.
|
||||||
@@ -58,10 +102,10 @@ Use a normal trigger volume around the scoring area.
|
|||||||
Attach:
|
Attach:
|
||||||
|
|
||||||
- Condition: `PlayerInMinigame`
|
- Condition: `PlayerInMinigame`
|
||||||
- Effect: `ModifyPlayerScore`
|
- Effect: `ModifyScore`
|
||||||
- Fields: `Operation = ADD`, `Amount = <points>`
|
- Fields: `Operation = ADD`, `Amount = <points>`
|
||||||
|
|
||||||
If the score is team-based, use `ModifyTeamScore` instead.
|
If the score is team-based, use `ModifyScore` with `TargetType=TEAM`.
|
||||||
|
|
||||||
### Phase Gate
|
### Phase Gate
|
||||||
|
|
||||||
@@ -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. `DestinationId` and `SetSpawnPoint` values are coordinate strings, such as `100,64,200` or `world_name,100,64,200`; 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,21 +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.PlayerDisconnectEvent;
|
||||||
|
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.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;
|
||||||
@@ -54,15 +73,16 @@ import net.kewwbec.minigames.volume.effect.EndMinigameEffect;
|
|||||||
import net.kewwbec.minigames.volume.effect.LeaveMinigameQueueEffect;
|
import net.kewwbec.minigames.volume.effect.LeaveMinigameQueueEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ModifyCounterEffect;
|
import net.kewwbec.minigames.volume.effect.ModifyCounterEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ModifyObjectiveProgressEffect;
|
import net.kewwbec.minigames.volume.effect.ModifyObjectiveProgressEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerLivesEffect;
|
import net.kewwbec.minigames.volume.effect.ModifyLivesEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ModifyPlayerScoreEffect;
|
import net.kewwbec.minigames.volume.effect.ModifyScoreEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ModifyTeamScoreEffect;
|
|
||||||
import net.kewwbec.minigames.mount.LockMountComponent;
|
import net.kewwbec.minigames.mount.LockMountComponent;
|
||||||
import net.kewwbec.minigames.mount.LockMountPacketHandler;
|
import net.kewwbec.minigames.mount.LockMountPacketHandler;
|
||||||
import net.kewwbec.minigames.volume.effect.DismountPlayerEffect;
|
import net.kewwbec.minigames.volume.effect.DismountPlayerEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.MountPlayerEffect;
|
import net.kewwbec.minigames.volume.effect.MountPlayerEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.PlaceBlocksEffect;
|
import net.kewwbec.minigames.volume.effect.PlaceBlocksEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.RespawnPlayerEffect;
|
import net.kewwbec.minigames.volume.effect.RespawnPlayerEffect;
|
||||||
|
import net.kewwbec.minigames.volume.effect.RespawnAllPlayersEffect;
|
||||||
|
import net.kewwbec.minigames.volume.effect.SwapTeamsEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ResetMinigameEffect;
|
import net.kewwbec.minigames.volume.effect.ResetMinigameEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.ResetScoresEffect;
|
import net.kewwbec.minigames.volume.effect.ResetScoresEffect;
|
||||||
import net.kewwbec.minigames.volume.effect.RestoreInventoryEffect;
|
import net.kewwbec.minigames.volume.effect.RestoreInventoryEffect;
|
||||||
@@ -91,11 +111,13 @@ 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;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
@@ -104,8 +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> 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);
|
||||||
@@ -122,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>())
|
||||||
@@ -131,10 +160,17 @@ 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 dataRoot = Path.of("minigame-core");
|
|
||||||
var runtimeService = new DefaultMinigameRuntimeService();
|
var runtimeService = new DefaultMinigameRuntimeService();
|
||||||
var minigameService = new DefaultMinigameService(dataRoot, runtimeService);
|
var minigameService = new DefaultMinigameService(runtimeService);
|
||||||
var adapters = new HytaleServerAdapters();
|
var adapters = new HytaleServerAdapters();
|
||||||
var signals = new MinigameVolumeSignalService();
|
var signals = new MinigameVolumeSignalService();
|
||||||
runtimeService.setSignalService(signals);
|
runtimeService.setSignalService(signals);
|
||||||
@@ -143,7 +179,6 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
runtimeService.setInventoryAdapter(adapters);
|
runtimeService.setInventoryAdapter(adapters);
|
||||||
var menuService = new MinigameMenuService();
|
var menuService = new MinigameMenuService();
|
||||||
menuService.setPlayerAdapter(adapters);
|
menuService.setPlayerAdapter(adapters);
|
||||||
runtimeService.setMenuService(menuService);
|
|
||||||
getEntityStoreRegistry().registerSystem(new MinigameMenuOpenSystem(menuService));
|
getEntityStoreRegistry().registerSystem(new MinigameMenuOpenSystem(menuService));
|
||||||
var hudService = new MinigameHudService();
|
var hudService = new MinigameHudService();
|
||||||
hudService.setPlayerAdapter(adapters);
|
hudService.setPlayerAdapter(adapters);
|
||||||
@@ -151,17 +186,62 @@ 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);
|
||||||
getEntityStoreRegistry().registerSystem(new MinigamePregameSystem(runtimeService));
|
var statsService = setupStats(adapters);
|
||||||
this.services = new MinigameServices(minigameService, runtimeService, adapters, new MinigameMapDiscoveryService(), queueService, signals);
|
this.services = new MinigameServices(minigameService, runtimeService, adapters,
|
||||||
|
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));
|
||||||
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)
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.PvpGuard());
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.BlockBreakGuard());
|
||||||
|
getEntityStoreRegistry().registerSystem(new MinigameRuleEnforcementSystems.BlockPlaceGuard());
|
||||||
|
|
||||||
|
// Disconnect/reconnect handling: queue removal, LEFT status, AllowJoinMidgame rejoin
|
||||||
|
var connectionListener = new MinigameConnectionListener(services);
|
||||||
|
getEventRegistry().registerGlobal(PlayerDisconnectEvent.class, connectionListener::onPlayerDisconnect);
|
||||||
|
getEventRegistry().registerGlobal(PlayerConnectEvent.class, connectionListener::onPlayerConnect);
|
||||||
|
|
||||||
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
|
||||||
@@ -170,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,8 +275,20 @@ 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
|
||||||
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
public static ComponentType<EntityStore, LockMountComponent> getLockMountComponentType() {
|
||||||
return instance.lockMountComponentType;
|
MinigameCorePlugin plugin = instance;
|
||||||
|
return plugin != null ? plugin.lockMountComponentType : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void injectTriggerVolumeCloneCommand() {
|
private void injectTriggerVolumeCloneCommand() {
|
||||||
@@ -213,10 +312,26 @@ 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) {
|
||||||
LOGGER.at(Level.WARNING).withCause(e).log("Failed to inject /triggervolume clone command");
|
// Reflection into CommandManager internals: breaks silently on server updates,
|
||||||
|
// so make the health check unmissable for admins reading the log.
|
||||||
|
LOGGER.at(Level.SEVERE).withCause(e).log(
|
||||||
|
"HEALTH CHECK FAILED: /triggervolume clone, clonegroup, minigametemplate, templategroup, and minigametemplates are UNAVAILABLE. "
|
||||||
|
+ "The server build likely changed CommandManager internals; core-minigames needs an update.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +342,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerTriggerVolumeEffectType(triggerVolumes, ResetMinigameEffect.TYPE_ID, ResetMinigameEffect.class, ResetMinigameEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, ResetMinigameEffect.TYPE_ID, ResetMinigameEffect.class, ResetMinigameEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID, SetMinigamePhaseEffect.class, SetMinigamePhaseEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID, SetMinigamePhaseEffect.class, SetMinigamePhaseEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, ResetScoresEffect.TYPE_ID, ResetScoresEffect.class, ResetScoresEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, ResetScoresEffect.TYPE_ID, ResetScoresEffect.class, ResetScoresEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyPlayerScoreEffect.TYPE_ID, ModifyPlayerScoreEffect.class, ModifyPlayerScoreEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, ModifyScoreEffect.TYPE_ID, ModifyScoreEffect.class, ModifyScoreEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyTeamScoreEffect.TYPE_ID, ModifyTeamScoreEffect.class, ModifyTeamScoreEffect.CODEC);
|
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyCounterEffect.TYPE_ID, ModifyCounterEffect.class, ModifyCounterEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, ModifyCounterEffect.TYPE_ID, ModifyCounterEffect.class, ModifyCounterEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID, ModifyPlayerLivesEffect.class, ModifyPlayerLivesEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, ModifyLivesEffect.TYPE_ID, ModifyLivesEffect.class, ModifyLivesEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerStatusEffect.TYPE_ID, SetPlayerStatusEffect.class, SetPlayerStatusEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerStatusEffect.TYPE_ID, SetPlayerStatusEffect.class, SetPlayerStatusEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID, SetPlayerLoadoutEffect.class, SetPlayerLoadoutEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID, SetPlayerLoadoutEffect.class, SetPlayerLoadoutEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID, JoinMinigameQueueEffect.class, JoinMinigameQueueEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID, JoinMinigameQueueEffect.class, JoinMinigameQueueEffect.CODEC);
|
||||||
@@ -246,6 +360,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerTriggerVolumeEffectType(triggerVolumes, StopTimerEffect.TYPE_ID, StopTimerEffect.class, StopTimerEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, StopTimerEffect.TYPE_ID, StopTimerEffect.class, StopTimerEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SetSpawnPointEffect.TYPE_ID, SetSpawnPointEffect.class, SetSpawnPointEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SetSpawnPointEffect.TYPE_ID, SetSpawnPointEffect.class, SetSpawnPointEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, RespawnPlayerEffect.TYPE_ID, RespawnPlayerEffect.class, RespawnPlayerEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, RespawnPlayerEffect.TYPE_ID, RespawnPlayerEffect.class, RespawnPlayerEffect.CODEC);
|
||||||
|
registerTriggerVolumeEffectType(triggerVolumes, RespawnAllPlayersEffect.TYPE_ID, RespawnAllPlayersEffect.class, RespawnAllPlayersEffect.CODEC);
|
||||||
|
registerTriggerVolumeEffectType(triggerVolumes, SwapTeamsEffect.TYPE_ID, SwapTeamsEffect.class, SwapTeamsEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SendMinigameMessageEffect.TYPE_ID, SendMinigameMessageEffect.class, SendMinigameMessageEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SendMinigameMessageEffect.TYPE_ID, SendMinigameMessageEffect.class, SendMinigameMessageEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, AssignTeamEffect.TYPE_ID, AssignTeamEffect.class, AssignTeamEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, AssignTeamEffect.TYPE_ID, AssignTeamEffect.class, AssignTeamEffect.CODEC);
|
||||||
registerTriggerVolumeEffectType(triggerVolumes, SaveInventoryEffect.TYPE_ID, SaveInventoryEffect.class, SaveInventoryEffect.CODEC);
|
registerTriggerVolumeEffectType(triggerVolumes, SaveInventoryEffect.TYPE_ID, SaveInventoryEffect.class, SaveInventoryEffect.CODEC);
|
||||||
@@ -266,6 +382,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
@Nonnull Class<T> clazz,
|
@Nonnull Class<T> clazz,
|
||||||
@Nonnull BuilderCodec<T> codec
|
@Nonnull BuilderCodec<T> codec
|
||||||
) {
|
) {
|
||||||
|
ownEffectTypeIds.add(typeId);
|
||||||
if (TriggerEffect.CODEC.getCodecFor(typeId) != null) {
|
if (TriggerEffect.CODEC.getCodecFor(typeId) != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -277,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);
|
||||||
@@ -297,6 +459,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
@Nonnull Class<T> clazz,
|
@Nonnull Class<T> clazz,
|
||||||
@Nonnull BuilderCodec<T> codec
|
@Nonnull BuilderCodec<T> codec
|
||||||
) {
|
) {
|
||||||
|
ownConditionTypeIds.add(typeId);
|
||||||
if (TriggerCondition.CODEC.getCodecFor(typeId) == null) {
|
if (TriggerCondition.CODEC.getCodecFor(typeId) == null) {
|
||||||
TriggerCondition.CODEC.register(typeId, clazz, codec);
|
TriggerCondition.CODEC.register(typeId, clazz, codec);
|
||||||
}
|
}
|
||||||
@@ -309,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();
|
||||||
@@ -319,10 +483,9 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerMinigameIdAssetField(triggerVolumes, ResetMinigameEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ResetMinigameEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SetMinigamePhaseEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, ResetScoresEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ResetScoresEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, ModifyPlayerScoreEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ModifyScoreEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, ModifyTeamScoreEffect.TYPE_ID);
|
|
||||||
registerMinigameIdAssetField(triggerVolumes, ModifyCounterEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ModifyCounterEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, ModifyPlayerLivesEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ModifyLivesEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerStatusEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SetPlayerStatusEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SetPlayerLoadoutEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, JoinMinigameQueueEffect.TYPE_ID);
|
||||||
@@ -330,6 +493,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerMinigameIdAssetField(triggerVolumes, VoteForMapEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, VoteForMapEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, StartQueuedMinigameEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SpawnItemEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SpawnItemEffect.TYPE_ID);
|
||||||
|
triggerVolumes.registerAssetField(SpawnItemEffect.TYPE_ID, "ItemId", "Item");
|
||||||
registerMinigameIdAssetField(triggerVolumes, SpawnNpcEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SpawnNpcEffect.TYPE_ID);
|
||||||
triggerVolumes.registerAssetField(SpawnNpcEffect.TYPE_ID, "RoleId", "NpcRole");
|
triggerVolumes.registerAssetField(SpawnNpcEffect.TYPE_ID, "RoleId", "NpcRole");
|
||||||
registerMinigameIdAssetField(triggerVolumes, MountPlayerEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, MountPlayerEffect.TYPE_ID);
|
||||||
@@ -343,6 +507,8 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerMinigameIdAssetField(triggerVolumes, StopTimerEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, StopTimerEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SetSpawnPointEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SetSpawnPointEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, RespawnPlayerEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, RespawnPlayerEffect.TYPE_ID);
|
||||||
|
registerMinigameIdAssetField(triggerVolumes, RespawnAllPlayersEffect.TYPE_ID);
|
||||||
|
registerMinigameIdAssetField(triggerVolumes, SwapTeamsEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SendMinigameMessageEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SendMinigameMessageEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, AssignTeamEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, AssignTeamEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, SaveInventoryEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, SaveInventoryEffect.TYPE_ID);
|
||||||
@@ -352,6 +518,7 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, ModifyObjectiveProgressEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, AwardKillScoreEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, PlaceBlocksEffect.TYPE_ID);
|
||||||
|
triggerVolumes.registerAssetField(PlaceBlocksEffect.TYPE_ID, "BlockId", "BlockType");
|
||||||
registerMinigameIdAssetField(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, OpenMinigameQueueUIEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, StartPlayerTimerEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, StartPlayerTimerEffect.TYPE_ID);
|
||||||
registerMinigameIdAssetField(triggerVolumes, StopPlayerTimerEffect.TYPE_ID);
|
registerMinigameIdAssetField(triggerVolumes, StopPlayerTimerEffect.TYPE_ID);
|
||||||
@@ -374,44 +541,17 @@ public final class MinigameCorePlugin extends JavaPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int registeredTriggerEffectCount() {
|
private int registeredTriggerEffectCount() {
|
||||||
int count = 0;
|
var registered = TriggerEffect.CODEC.getRegisteredIds();
|
||||||
for (String typeId : TriggerEffect.CODEC.getRegisteredIds()) {
|
return (int) ownEffectTypeIds.stream().filter(registered::contains).count();
|
||||||
if (typeId.startsWith("StartMinigame") || typeId.startsWith("EndMinigame")
|
|
||||||
|| typeId.startsWith("ResetMinigame") || typeId.startsWith("SetMinigame")
|
|
||||||
|| typeId.startsWith("ResetScores") || typeId.startsWith("ModifyPlayer")
|
|
||||||
|| typeId.startsWith("ModifyTeam") || typeId.startsWith("ModifyCounter")
|
|
||||||
|| typeId.startsWith("ModifyObjective") || typeId.startsWith("SetSpawn")
|
|
||||||
|| typeId.startsWith("SetObjective") || typeId.startsWith("SetPlayerStatus")
|
|
||||||
|| typeId.startsWith("SetRound") || typeId.startsWith("Start") || typeId.startsWith("Stop")
|
|
||||||
|| typeId.equals(JoinMinigameQueueEffect.TYPE_ID) || typeId.equals(LeaveMinigameQueueEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(VoteForMapEffect.TYPE_ID) || typeId.equals(StartQueuedMinigameEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(SpawnItemEffect.TYPE_ID) || typeId.equals(SpawnNpcEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(MountPlayerEffect.TYPE_ID) || typeId.equals(DismountPlayerEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(SetPlayerLoadoutEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(RespawnPlayerEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(SendMinigameMessageEffect.TYPE_ID) || typeId.equals(AssignTeamEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(SaveInventoryEffect.TYPE_ID) || typeId.equals(RestoreInventoryEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(ClearInventoryEffect.TYPE_ID) || typeId.equals(AwardKillScoreEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(PlaceBlocksEffect.TYPE_ID)
|
|
||||||
|| typeId.equals(OpenMinigameQueueUIEffect.TYPE_ID)) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private int registeredTriggerConditionCount() {
|
private int registeredTriggerConditionCount() {
|
||||||
int count = 0;
|
var registered = TriggerCondition.CODEC.getRegisteredIds();
|
||||||
for (String typeId : TriggerCondition.CODEC.getRegisteredIds()) {
|
return (int) ownConditionTypeIds.stream().filter(registered::contains).count();
|
||||||
if (typeId.equals(MinigamePhaseCondition.TYPE_ID) || typeId.equals(PlayerInMinigameCondition.TYPE_ID)
|
}
|
||||||
|| typeId.equals(PlayerStatusCondition.TYPE_ID) || typeId.equals(PlayerScoreCondition.TYPE_ID)
|
|
||||||
|| typeId.equals(TeamScoreCondition.TYPE_ID) || typeId.equals(CounterCondition.TYPE_ID)
|
private int registeredInteractionCount() {
|
||||||
|| typeId.equals(CurrentRoundCondition.TYPE_ID) || typeId.equals(PlayerLivesCondition.TYPE_ID)
|
var registered = Interaction.CODEC.getRegisteredIds();
|
||||||
|| typeId.equals(TeamCondition.TYPE_ID) || typeId.equals(TimerElapsedCondition.TYPE_ID)
|
return (int) ownInteractionTypeIds.stream().filter(registered::contains).count();
|
||||||
|| typeId.equals(ObjectiveStatusCondition.TYPE_ID) || typeId.equals(WinConditionMetCondition.TYPE_ID)) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 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;
|
||||||
@@ -32,7 +36,6 @@ import java.util.Locale;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.CompletableFuture;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
public final class HytaleServerAdapters implements
|
public final class HytaleServerAdapters implements
|
||||||
@@ -79,16 +82,58 @@ public final class HytaleServerAdapters implements
|
|||||||
@Override
|
@Override
|
||||||
public void setGameMode(String playerId, GameMode gameMode) {
|
public void setGameMode(String playerId, GameMode gameMode) {
|
||||||
if (gameMode == null) return;
|
if (gameMode == null) return;
|
||||||
withPlayerRef(playerId, ref -> {
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
Player.setGameMode(ref, gameMode, ref.getStore());
|
Player.setGameMode(ref, gameMode, ref.getStore());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
withPlayerRef(playerId, ref -> {
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
Item item = Item.getAssetMap().getAsset(itemId);
|
Item item = Item.getAssetMap().getAsset(itemId);
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
ItemStack stack = new ItemStack(item.getId(), amount);
|
ItemStack stack = new ItemStack(item.getId(), amount);
|
||||||
@@ -145,7 +190,7 @@ public final class HytaleServerAdapters implements
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void restoreInventory(String playerId, Map<String, Object> inventory) {
|
public void restoreInventory(String playerId, Map<String, Object> inventory) {
|
||||||
withPlayerRef(playerId, ref -> {
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
var store = ref.getStore();
|
var store = ref.getStore();
|
||||||
restoreInventory(store, ref, "armor", inventory, InventoryComponent.Armor.getComponentType());
|
restoreInventory(store, ref, "armor", inventory, InventoryComponent.Armor.getComponentType());
|
||||||
restoreInventory(store, ref, "hotbar", inventory, InventoryComponent.Hotbar.getComponentType());
|
restoreInventory(store, ref, "hotbar", inventory, InventoryComponent.Hotbar.getComponentType());
|
||||||
@@ -175,7 +220,7 @@ public final class HytaleServerAdapters implements
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void clearInventory(String playerId) {
|
public void clearInventory(String playerId) {
|
||||||
withPlayerRef(playerId, ref -> {
|
withPlayerRefAsync(playerId, ref -> {
|
||||||
InventoryUtils.clear(ref, ref.getStore());
|
InventoryUtils.clear(ref, ref.getStore());
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -242,6 +287,30 @@ public final class HytaleServerAdapters implements
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a void operation on the player's world thread without blocking the caller.
|
||||||
|
* Blocking another world's executor with join() can deadlock when two worlds
|
||||||
|
* call into each other in the same tick.
|
||||||
|
*/
|
||||||
|
private void withPlayerRefAsync(String playerId, WorldOperation<?> operation) {
|
||||||
|
findPlayer(playerId).ifPresent(player -> {
|
||||||
|
var ref = player.getReference();
|
||||||
|
if (ref == null || !ref.isValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var world = ref.getStore().getExternalData().getWorld();
|
||||||
|
runInWorld(world, () -> {
|
||||||
|
if (ref.isValid()) {
|
||||||
|
operation.run(ref);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an operation that needs a result. Only safe on the player's own world thread;
|
||||||
|
* cross-world calls return empty instead of blocking.
|
||||||
|
*/
|
||||||
private <T> Optional<T> withPlayerRef(String playerId, WorldOperation<T> operation) {
|
private <T> Optional<T> withPlayerRef(String playerId, WorldOperation<T> operation) {
|
||||||
return findPlayer(playerId).flatMap(player -> {
|
return findPlayer(playerId).flatMap(player -> {
|
||||||
var ref = player.getReference();
|
var ref = player.getReference();
|
||||||
@@ -252,7 +321,9 @@ public final class HytaleServerAdapters implements
|
|||||||
if (world.isInThread()) {
|
if (world.isInThread()) {
|
||||||
return Optional.ofNullable(operation.run(ref));
|
return Optional.ofNullable(operation.run(ref));
|
||||||
}
|
}
|
||||||
return Optional.ofNullable(CompletableFuture.supplyAsync(() -> operation.run(ref), world).join());
|
LOGGER.atWarning().log("Cross-world synchronous adapter call for player=%s (world=%s) refused to avoid deadlock",
|
||||||
|
playerId, world.getName());
|
||||||
|
return Optional.empty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +331,7 @@ public final class HytaleServerAdapters implements
|
|||||||
if (world.isInThread()) {
|
if (world.isInThread()) {
|
||||||
action.run();
|
action.run();
|
||||||
} else {
|
} else {
|
||||||
CompletableFuture.runAsync(action, world).join();
|
world.execute(action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +364,13 @@ public final class HytaleServerAdapters implements
|
|||||||
if (parts.length == 3 || parts.length == 6) {
|
if (parts.length == 3 || parts.length == 6) {
|
||||||
return coordinates(currentWorld, parts, 0);
|
return coordinates(currentWorld, parts, 0);
|
||||||
}
|
}
|
||||||
|
var universe = Universe.get();
|
||||||
if (parts.length == 4 || parts.length == 7) {
|
if (parts.length == 4 || parts.length == 7) {
|
||||||
var world = Universe.get().getWorld(parts[0]);
|
var world = universe != null ? universe.getWorld(parts[0]) : null;
|
||||||
return world == null ? null : coordinates(world, parts, 1);
|
return world == null ? null : coordinates(world, parts, 1);
|
||||||
}
|
}
|
||||||
if (destinationId.toLowerCase(Locale.ROOT).startsWith("world:")) {
|
if (destinationId.toLowerCase(Locale.ROOT).startsWith("world:")) {
|
||||||
var world = Universe.get().getWorld(destinationId.substring("world:".length()));
|
var world = universe != null ? universe.getWorld(destinationId.substring("world:".length())) : null;
|
||||||
return world == null ? null : new Destination(world, new Transform());
|
return world == null ? null : new Destination(world, new Transform());
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ 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.command.sub.volume.MinigameVolumeCommand;
|
|
||||||
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");
|
||||||
|
|
||||||
@@ -19,8 +19,6 @@ public final class MinigameCommand extends AbstractCommandCollection {
|
|||||||
addSubCommand(new MinigameStopCommand(services));
|
addSubCommand(new MinigameStopCommand(services));
|
||||||
addSubCommand(new MinigameResetCommand(services));
|
addSubCommand(new MinigameResetCommand(services));
|
||||||
addSubCommand(new MinigameValidateCommand(services));
|
addSubCommand(new MinigameValidateCommand(services));
|
||||||
addSubCommand(new MinigameExportCommand(services));
|
|
||||||
addSubCommand(new MinigameImportCommand(services));
|
|
||||||
addSubCommand(new MinigameListCommand(services));
|
addSubCommand(new MinigameListCommand(services));
|
||||||
addSubCommand(new MinigameInfoCommand(services));
|
addSubCommand(new MinigameInfoCommand(services));
|
||||||
addSubCommand(new MinigameMapsCommand(services));
|
addSubCommand(new MinigameMapsCommand(services));
|
||||||
@@ -28,6 +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 MinigameVolumeCommand(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,40 +1,68 @@
|
|||||||
package net.kewwbec.minigames.command.sub;
|
package net.kewwbec.minigames.command.sub;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.assetstore.AssetPack;
|
||||||
import com.hypixel.hytale.server.core.Message;
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.asset.AssetModule;
|
||||||
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.RequiredArg;
|
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.arguments.types.ArgTypes;
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.service.DefaultMinigameService;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
public final class MinigameCreateCommand extends CommandBase {
|
public final class MinigameCreateCommand extends CommandBase {
|
||||||
private final MinigameServices services;
|
private final MinigameServices services;
|
||||||
|
private final RequiredArg<String> packArg;
|
||||||
private final RequiredArg<String> idArg;
|
private final RequiredArg<String> idArg;
|
||||||
private final RequiredArg<String> nameArg;
|
private final RequiredArg<String> nameArg;
|
||||||
|
|
||||||
public MinigameCreateCommand(MinigameServices services) {
|
public MinigameCreateCommand(MinigameServices services) {
|
||||||
super("create", "minigames.command.spec.create.description");
|
super("create", "minigames.command.spec.create.description");
|
||||||
this.services = services;
|
this.services = services;
|
||||||
|
this.packArg = withRequiredArg("pack", "minigames.command.spec.create.arg.pack", ArgTypes.STRING);
|
||||||
this.idArg = withRequiredArg("id", "minigames.command.spec.create.arg.id", ArgTypes.STRING);
|
this.idArg = withRequiredArg("id", "minigames.command.spec.create.arg.id", ArgTypes.STRING);
|
||||||
this.nameArg = withRequiredArg("name", "minigames.command.spec.create.arg.name", ArgTypes.GREEDY_STRING);
|
this.nameArg = withRequiredArg("name", "minigames.command.spec.create.arg.name", ArgTypes.GREEDY_STRING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||||
|
String pack = ctx.get(packArg);
|
||||||
String id = ctx.get(idArg);
|
String id = ctx.get(idArg);
|
||||||
String name = ctx.get(nameArg);
|
String name = ctx.get(nameArg);
|
||||||
|
|
||||||
|
if (!DefaultMinigameService.isValidId(id)) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.error.invalid_id").param("id", id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AssetPack assetPack = AssetModule.get().getAssetPack(pack);
|
||||||
|
if (assetPack == null) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.error.pack_not_found").param("pack", pack));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (assetPack.isImmutable()) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.error.pack_immutable").param("pack", pack));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (services.minigames().definition(id).isPresent()) {
|
if (services.minigames().definition(id).isPresent()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.already_exists").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.error.already_exists").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MinigameDefinition def = services.minigames().create(id, name);
|
try {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.create.success")
|
MinigameDefinition def = services.minigames().create(pack, id, name);
|
||||||
.param("id", def.getId())
|
ctx.sendMessage(Message.translation("minigames.command.create.success")
|
||||||
.param("name", def.getName()));
|
.param("id", def.getId())
|
||||||
|
.param("name", def.getName())
|
||||||
|
.param("pack", pack));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.create.error.save_failed")
|
||||||
|
.param("id", id)
|
||||||
|
.param("error", String.valueOf(e.getMessage())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ public final class MinigameDeleteCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!services.runtime().runtimes(id).isEmpty()) {
|
|
||||||
services.runtime().end(id, "minigames.runtime.reason.deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
services.minigames().delete(id);
|
services.minigames().delete(id);
|
||||||
ctx.sendMessage(Message.translation("minigames.command.delete.success").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.delete.success").param("id", id));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,26 +8,62 @@ import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
|||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a simple definition property and persists it back into its asset pack:
|
||||||
|
* {@code /minigame edit <id> <name|description|enabled> <value>}.
|
||||||
|
* Anything richer should go through the Hytale asset editor.
|
||||||
|
*/
|
||||||
public final class MinigameEditCommand extends CommandBase {
|
public final class MinigameEditCommand extends CommandBase {
|
||||||
private final MinigameServices services;
|
private final MinigameServices services;
|
||||||
private final RequiredArg<String> idArg;
|
private final RequiredArg<String> idArg;
|
||||||
|
private final RequiredArg<String> propertyArg;
|
||||||
|
private final RequiredArg<String> valueArg;
|
||||||
|
|
||||||
public MinigameEditCommand(MinigameServices services) {
|
public MinigameEditCommand(MinigameServices services) {
|
||||||
super("edit", "minigames.command.spec.edit.description");
|
super("edit", "minigames.command.spec.edit.description");
|
||||||
this.services = services;
|
this.services = services;
|
||||||
this.idArg = withRequiredArg("id", "minigames.command.spec.edit.arg.id", ArgTypes.STRING);
|
this.idArg = withRequiredArg("id", "minigames.command.spec.edit.arg.id", ArgTypes.STRING);
|
||||||
|
this.propertyArg = withRequiredArg("property", "minigames.command.spec.edit.arg.property", ArgTypes.STRING);
|
||||||
|
this.valueArg = withRequiredArg("value", "minigames.command.spec.edit.arg.value", ArgTypes.GREEDY_STRING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||||
String id = ctx.get(idArg);
|
String id = ctx.get(idArg);
|
||||||
|
String property = ctx.get(propertyArg).toLowerCase(Locale.ROOT);
|
||||||
|
String value = ctx.get(valueArg);
|
||||||
|
|
||||||
if (services.minigames().definition(id).isEmpty()) {
|
var defOpt = services.minigames().definition(id);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "edit"));
|
var def = defOpt.get();
|
||||||
|
switch (property) {
|
||||||
|
case "name" -> def.setName(value);
|
||||||
|
case "description" -> def.setDescription(value);
|
||||||
|
case "enabled" -> def.setEnabled(Boolean.parseBoolean(value));
|
||||||
|
default -> {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.edit.error.unknown_property")
|
||||||
|
.param("property", property)
|
||||||
|
.param("valid", "name, description, enabled"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
services.minigames().update(def);
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.edit.success")
|
||||||
|
.param("id", id)
|
||||||
|
.param("property", property)
|
||||||
|
.param("value", value));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.edit.error.save_failed")
|
||||||
|
.param("id", id)
|
||||||
|
.param("error", String.valueOf(e.getMessage())));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
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.RequiredArg;
|
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameExportCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> idArg;
|
|
||||||
|
|
||||||
public MinigameExportCommand(MinigameServices services) {
|
|
||||||
super("export", "minigames.command.spec.export.description");
|
|
||||||
this.services = services;
|
|
||||||
this.idArg = withRequiredArg("id", "minigames.command.spec.export.arg.id", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
String id = ctx.get(idArg);
|
|
||||||
|
|
||||||
if (services.minigames().definition(id).isEmpty()) {
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "export"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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.RequiredArg;
|
|
||||||
import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes;
|
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameImportCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> sourceArg;
|
|
||||||
|
|
||||||
public MinigameImportCommand(MinigameServices services) {
|
|
||||||
super("import", "minigames.command.spec.import.description");
|
|
||||||
this.services = services;
|
|
||||||
this.sourceArg = withRequiredArg("source", "minigames.command.spec.import.arg.source", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "import"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,7 @@ public final class MinigameInfoCommand extends CommandBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MinigameDefinition d = def.get();
|
MinigameDefinition d = def.get();
|
||||||
boolean running = !services.runtime().runtimes(id).isEmpty();
|
boolean running = !services.runtime().runtimesForMinigame(id).isEmpty();
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.info.header")
|
ctx.sendMessage(Message.translation("minigames.command.info.header")
|
||||||
.param("id", d.getId())
|
.param("id", d.getId())
|
||||||
|
|||||||
@@ -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,7 +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.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.volume.effect.SetPlayerLoadoutEffect;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -27,7 +33,8 @@ public final class MinigameJoinCommand extends CommandBase {
|
|||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||||
String id = ctx.get(idArg);
|
String id = ctx.get(idArg);
|
||||||
|
|
||||||
if (services.minigames().definition(id).isEmpty()) {
|
var defOpt = services.minigames().definition(id);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,70 @@ public final class MinigameJoinCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
services.queue().join(id, target.getUuid().toString());
|
String playerId = target.getUuid().toString();
|
||||||
ctx.sendMessage(Message.raw("[Minigames] Joined queue for " + id + "."));
|
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) {
|
||||||
|
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 QUEUE_FULL -> ctx.sendMessage(Message.translation("minigames.queue.full").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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ public final class MinigameLeaveCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "leave"));
|
String playerId = target.getUuid().toString();
|
||||||
|
services.queue().leaveAll(playerId);
|
||||||
|
|
||||||
|
var runtime = services.runtime().runtimeForPlayer(playerId).orElse(null);
|
||||||
|
if (runtime == null) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.leave.not_in_game"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
services.runtime().removePlayer(runtime, playerId, "left");
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.leave.success").param("id", runtime.minigameId()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public final class MinigameListCommand extends CommandBase {
|
|||||||
.param("count", defs.size());
|
.param("count", defs.size());
|
||||||
|
|
||||||
for (MinigameDefinition def : defs.stream().sorted(java.util.Comparator.comparing(MinigameDefinition::getId)).toList()) {
|
for (MinigameDefinition def : defs.stream().sorted(java.util.Comparator.comparing(MinigameDefinition::getId)).toList()) {
|
||||||
boolean running = !services.runtime().runtimes(def.getId()).isEmpty();
|
boolean running = !services.runtime().runtimesForMinigame(def.getId()).isEmpty();
|
||||||
list.insert("\n").insert(
|
list.insert("\n").insert(
|
||||||
Message.translation("minigames.command.list.entry")
|
Message.translation("minigames.command.list.entry")
|
||||||
.param("id", def.getId())
|
.param("id", def.getId())
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 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;
|
||||||
@@ -27,12 +28,18 @@ public final class MinigameSpectateCommand extends CommandBase {
|
|||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
protected void executeSync(@Nonnull CommandContext ctx) {
|
||||||
String id = ctx.get(idArg);
|
String id = ctx.get(idArg);
|
||||||
|
|
||||||
if (services.minigames().definition(id).isEmpty()) {
|
var defOpt = services.minigames().definition(id);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!defOpt.get().isAllowSpectators()) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.spectate.error.not_allowed").param("id", id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (services.runtime().runtimes(id).isEmpty()) {
|
var runtimes = services.runtime().runtimesForMinigame(id);
|
||||||
|
if (runtimes.isEmpty()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.spectate.error.not_running").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.spectate.error.not_running").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -43,6 +50,16 @@ public final class MinigameSpectateCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "spectate"));
|
String playerId = target.getUuid().toString();
|
||||||
|
if (services.runtime().runtimeForPlayer(playerId).isPresent()) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.spectate.error.already_in_game"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MinigameRuntime runtime = runtimes.iterator().next();
|
||||||
|
services.runtime().onPlayerSpectate(runtime, playerId);
|
||||||
|
|
||||||
|
SpectatorArenaSupport.teleportToArena(services, target, runtime);
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.spectate.success").param("id", id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|||||||
import com.hypixel.hytale.component.Store;
|
import com.hypixel.hytale.component.Store;
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.service.MinigameStarter;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -41,8 +42,12 @@ public final class MinigameStartCommand extends CommandBase {
|
|||||||
|
|
||||||
PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null;
|
PlayerRef player = ctx.sender() instanceof PlayerRef playerRef ? playerRef : null;
|
||||||
Store<EntityStore> store = player != null && player.getReference() != null ? player.getReference().getStore() : null;
|
Store<EntityStore> store = player != null && player.getReference() != null ? player.getReference().getStore() : null;
|
||||||
var discovered = services.maps().discover(store, id);
|
// Admin-started games skip the min-player pregame check.
|
||||||
services.queue().startQueued(def.get(), discovered.candidates(), services.runtime());
|
var runtime = MinigameStarter.startQueued(services, def.get(), store, true);
|
||||||
ctx.sendMessage(Message.translation("minigames.command.start.success").param("id", id));
|
if (runtime != null) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.start.success").param("id", id));
|
||||||
|
} else {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.start.error.refused").param("id", id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ public final class MinigameStopCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (services.runtime().runtimes(id).isEmpty()) {
|
if (services.runtime().runtimesForMinigame(id).isEmpty()) {
|
||||||
ctx.sendMessage(Message.translation("minigames.command.stop.error.not_running").param("id", id));
|
ctx.sendMessage(Message.translation("minigames.command.stop.error.not_running").param("id", id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ public final class MinigameVoteCommand extends CommandBase {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
services.queue().vote(id, player.getUuid().toString(), mapId);
|
boolean voted = services.queue().vote(id, player.getUuid().toString(), mapId);
|
||||||
ctx.sendMessage(Message.raw("[Minigames] Voted for " + mapId + " in " + id + "."));
|
if (voted) {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.vote.success").param("map", mapId).param("id", id));
|
||||||
|
} else {
|
||||||
|
ctx.sendMessage(Message.translation("minigames.command.vote.error.not_queued").param("id", id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package net.kewwbec.minigames.command.sub.volume;
|
|
||||||
|
|
||||||
import com.hypixel.hytale.server.core.command.system.basecommands.AbstractCommandCollection;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
public final class MinigameVolumeCommand extends AbstractCommandCollection {
|
|
||||||
public MinigameVolumeCommand(MinigameServices services) {
|
|
||||||
super("volume", "minigames.command.spec.volume.description");
|
|
||||||
addSubCommand(new MinigameVolumeCreateCommand(services));
|
|
||||||
addSubCommand(new MinigameVolumeLinkCommand(services));
|
|
||||||
addSubCommand(new MinigameVolumeEditCommand(services));
|
|
||||||
addSubCommand(new MinigameVolumeTestCommand(services));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package net.kewwbec.minigames.command.sub.volume;
|
|
||||||
|
|
||||||
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.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameVolumeCreateCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> typeArg;
|
|
||||||
|
|
||||||
public MinigameVolumeCreateCommand(MinigameServices services) {
|
|
||||||
super("create", "minigames.command.spec.volume.create.description");
|
|
||||||
this.services = services;
|
|
||||||
this.typeArg = withRequiredArg("type", "minigames.command.spec.volume.create.arg.type", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
ctx.get(typeArg);
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "volume create"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package net.kewwbec.minigames.command.sub.volume;
|
|
||||||
|
|
||||||
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.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameVolumeEditCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> volumeIdArg;
|
|
||||||
|
|
||||||
public MinigameVolumeEditCommand(MinigameServices services) {
|
|
||||||
super("edit", "minigames.command.spec.volume.edit.description");
|
|
||||||
this.services = services;
|
|
||||||
this.volumeIdArg = withRequiredArg("volumeId", "minigames.command.spec.volume.edit.arg.volumeId", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "volume edit"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package net.kewwbec.minigames.command.sub.volume;
|
|
||||||
|
|
||||||
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.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameVolumeLinkCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> volumeIdArg;
|
|
||||||
private final RequiredArg<String> minigameIdArg;
|
|
||||||
|
|
||||||
public MinigameVolumeLinkCommand(MinigameServices services) {
|
|
||||||
super("link", "minigames.command.spec.volume.link.description");
|
|
||||||
this.services = services;
|
|
||||||
this.volumeIdArg = withRequiredArg("volumeId", "minigames.command.spec.volume.link.arg.volumeId", ArgTypes.STRING);
|
|
||||||
this.minigameIdArg = withRequiredArg("minigameId", "minigames.command.spec.volume.link.arg.minigameId", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
String minigameId = ctx.get(minigameIdArg);
|
|
||||||
|
|
||||||
if (services.minigames().definition(minigameId).isEmpty()) {
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_found").param("id", minigameId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "volume link"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package net.kewwbec.minigames.command.sub.volume;
|
|
||||||
|
|
||||||
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.CommandBase;
|
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class MinigameVolumeTestCommand extends CommandBase {
|
|
||||||
private final MinigameServices services;
|
|
||||||
private final RequiredArg<String> volumeIdArg;
|
|
||||||
|
|
||||||
public MinigameVolumeTestCommand(MinigameServices services) {
|
|
||||||
super("test", "minigames.command.spec.volume.test.description");
|
|
||||||
this.services = services;
|
|
||||||
this.volumeIdArg = withRequiredArg("volumeId", "minigames.command.spec.volume.test.arg.volumeId", ArgTypes.STRING);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void executeSync(@Nonnull CommandContext ctx) {
|
|
||||||
ctx.sendMessage(Message.translation("minigames.command.error.not_implemented").param("command", "volume test"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,10 +14,10 @@ import com.hypixel.hytale.codec.codecs.array.ArrayCodec;
|
|||||||
import com.hypixel.hytale.codec.validation.ValidatorCache;
|
import com.hypixel.hytale.codec.validation.ValidatorCache;
|
||||||
import com.hypixel.hytale.codec.validation.Validators;
|
import com.hypixel.hytale.codec.validation.Validators;
|
||||||
import com.hypixel.hytale.protocol.GameMode;
|
import com.hypixel.hytale.protocol.GameMode;
|
||||||
import net.kewwbec.minigames.model.Enums.GameType;
|
|
||||||
import net.kewwbec.minigames.model.Enums.FriendlyFireKillScoreMode;
|
import net.kewwbec.minigames.model.Enums.FriendlyFireKillScoreMode;
|
||||||
import net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
import net.kewwbec.minigames.model.Enums.GameType;
|
||||||
import net.kewwbec.minigames.model.Enums.TeamMode;
|
import net.kewwbec.minigames.model.Enums.TeamMode;
|
||||||
|
import net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
||||||
import net.kewwbec.minigames.model.Enums.WinCondition;
|
import net.kewwbec.minigames.model.Enums.WinCondition;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
@@ -158,6 +158,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
(def, parent) -> def.overtimeEnabled = parent.overtimeEnabled
|
(def, parent) -> def.overtimeEnabled = parent.overtimeEnabled
|
||||||
)
|
)
|
||||||
.add()
|
.add()
|
||||||
|
.<Integer>appendInherited(
|
||||||
|
new KeyedCodec<>("OvertimeSeconds", Codec.INTEGER, false),
|
||||||
|
(def, v) -> def.overtimeSeconds = v,
|
||||||
|
def -> def.overtimeSeconds,
|
||||||
|
(def, parent) -> def.overtimeSeconds = parent.overtimeSeconds
|
||||||
|
)
|
||||||
|
.add()
|
||||||
.<Boolean>appendInherited(
|
.<Boolean>appendInherited(
|
||||||
new KeyedCodec<>("SuddenDeathEnabled", Codec.BOOLEAN),
|
new KeyedCodec<>("SuddenDeathEnabled", Codec.BOOLEAN),
|
||||||
(def, v) -> def.suddenDeathEnabled = v,
|
(def, v) -> def.suddenDeathEnabled = v,
|
||||||
@@ -165,6 +172,13 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
(def, parent) -> def.suddenDeathEnabled = parent.suddenDeathEnabled
|
(def, parent) -> def.suddenDeathEnabled = parent.suddenDeathEnabled
|
||||||
)
|
)
|
||||||
.add()
|
.add()
|
||||||
|
.<Integer>appendInherited(
|
||||||
|
new KeyedCodec<>("SuddenDeathSeconds", Codec.INTEGER, false),
|
||||||
|
(def, v) -> def.suddenDeathSeconds = v,
|
||||||
|
def -> def.suddenDeathSeconds,
|
||||||
|
(def, parent) -> def.suddenDeathSeconds = parent.suddenDeathSeconds
|
||||||
|
)
|
||||||
|
.add()
|
||||||
.<WinCondition>appendInherited(
|
.<WinCondition>appendInherited(
|
||||||
new KeyedCodec<>("WinCondition", new EnumCodec<>(WinCondition.class)),
|
new KeyedCodec<>("WinCondition", new EnumCodec<>(WinCondition.class)),
|
||||||
(def, v) -> def.winCondition = v,
|
(def, v) -> def.winCondition = v,
|
||||||
@@ -354,7 +368,9 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
protected int countdownSeconds = 10;
|
protected int countdownSeconds = 10;
|
||||||
protected int defaultPlayerLives = 0;
|
protected int defaultPlayerLives = 0;
|
||||||
protected boolean overtimeEnabled = false;
|
protected boolean overtimeEnabled = false;
|
||||||
|
protected int overtimeSeconds = 60;
|
||||||
protected boolean suddenDeathEnabled = false;
|
protected boolean suddenDeathEnabled = false;
|
||||||
|
protected int suddenDeathSeconds = 60;
|
||||||
protected WinCondition winCondition = WinCondition.HIGHEST_SCORE;
|
protected WinCondition winCondition = WinCondition.HIGHEST_SCORE;
|
||||||
protected MapSelectionMode mapSelectionMode = MapSelectionMode.VOTE;
|
protected MapSelectionMode mapSelectionMode = MapSelectionMode.VOTE;
|
||||||
protected FriendlyFireKillScoreMode friendlyFireKillScoreMode = FriendlyFireKillScoreMode.NO_POINTS;
|
protected FriendlyFireKillScoreMode friendlyFireKillScoreMode = FriendlyFireKillScoreMode.NO_POINTS;
|
||||||
@@ -423,7 +439,9 @@ public class MinigameDefinition implements JsonAssetWithMap<String, DefaultAsset
|
|||||||
public int getCountdownSeconds() { return countdownSeconds; }
|
public int getCountdownSeconds() { return countdownSeconds; }
|
||||||
public int getDefaultPlayerLives() { return defaultPlayerLives; }
|
public int getDefaultPlayerLives() { return defaultPlayerLives; }
|
||||||
public boolean isOvertimeEnabled() { return overtimeEnabled; }
|
public boolean isOvertimeEnabled() { return overtimeEnabled; }
|
||||||
|
public int getOvertimeSeconds() { return overtimeSeconds; }
|
||||||
public boolean isSuddenDeathEnabled() { return suddenDeathEnabled; }
|
public boolean isSuddenDeathEnabled() { return suddenDeathEnabled; }
|
||||||
|
public int getSuddenDeathSeconds() { return suddenDeathSeconds; }
|
||||||
public WinCondition getWinCondition() { return winCondition != null ? winCondition : WinCondition.HIGHEST_SCORE; }
|
public WinCondition getWinCondition() { return winCondition != null ? winCondition : WinCondition.HIGHEST_SCORE; }
|
||||||
public MapSelectionMode getMapSelectionMode() { return mapSelectionMode != null ? mapSelectionMode : MapSelectionMode.VOTE; }
|
public MapSelectionMode getMapSelectionMode() { return mapSelectionMode != null ? mapSelectionMode : MapSelectionMode.VOTE; }
|
||||||
public FriendlyFireKillScoreMode getFriendlyFireKillScoreMode() { return friendlyFireKillScoreMode != null ? friendlyFireKillScoreMode : FriendlyFireKillScoreMode.NO_POINTS; }
|
public FriendlyFireKillScoreMode getFriendlyFireKillScoreMode() { return friendlyFireKillScoreMode != null ? friendlyFireKillScoreMode : FriendlyFireKillScoreMode.NO_POINTS; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ public final class LockMountPacketHandler implements SubPacketHandler {
|
|||||||
|
|
||||||
Store<EntityStore> store = ref.getStore();
|
Store<EntityStore> store = ref.getStore();
|
||||||
store.getExternalData().getWorld().execute(() -> {
|
store.getExternalData().getWorld().execute(() -> {
|
||||||
if (store.getComponent(ref, LockMountComponent.getComponentType()) != null) {
|
var lockType = LockMountComponent.getComponentType();
|
||||||
|
// lockType is null during plugin shutdown; treat as unlocked.
|
||||||
|
if (lockType != null && store.getComponent(ref, lockType) != null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package net.kewwbec.minigames.persistence;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.assetstore.AssetPack;
|
||||||
|
import com.hypixel.hytale.server.core.asset.AssetModule;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists minigame definitions through Hytale's asset store, inside a named asset pack.
|
||||||
|
* Definitions written this way live in {@code <pack>/Server/Minigames/<id>.json} and are
|
||||||
|
* picked up by the normal asset scan on the next server start.
|
||||||
|
*/
|
||||||
|
public final class AssetPackMinigameDefinitionStore implements MinigameDefinitionStore {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(String packName, MinigameDefinition definition) throws IOException {
|
||||||
|
AssetPack pack = resolvePack(packName);
|
||||||
|
Path relative = relativePathInPack(pack, definition.getId());
|
||||||
|
MinigameDefinition.getAssetStore().writeAssetToDisk(pack, Map.of(relative, definition));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(MinigameDefinition definition) throws IOException {
|
||||||
|
String packName = MinigameDefinition.getAssetMap().getAssetPack(definition.getId());
|
||||||
|
if (packName == null) {
|
||||||
|
throw new IOException("Minigame definition '" + definition.getId()
|
||||||
|
+ "' is not associated with an asset pack; create it with /minigame create <pack> <id> <name>");
|
||||||
|
}
|
||||||
|
save(packName, definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(String id) throws IOException {
|
||||||
|
String packName = MinigameDefinition.getAssetMap().getAssetPack(id);
|
||||||
|
Path path = MinigameDefinition.getAssetMap().getPath(id);
|
||||||
|
if (packName != null) {
|
||||||
|
AssetPack pack = AssetModule.get().getAssetPack(packName);
|
||||||
|
if (pack != null && pack.isImmutable()) {
|
||||||
|
throw new IOException("Minigame definition '" + id + "' belongs to immutable asset pack '"
|
||||||
|
+ packName + "' and cannot be deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MinigameDefinition.getAssetStore().removeAssets(Set.of(id));
|
||||||
|
if (path != null) {
|
||||||
|
Files.deleteIfExists(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AssetPack resolvePack(String packName) throws IOException {
|
||||||
|
AssetModule assetModule = AssetModule.get();
|
||||||
|
AssetPack pack = packName != null && !packName.isBlank()
|
||||||
|
? assetModule.getAssetPack(packName)
|
||||||
|
: assetModule.getBaseAssetPack();
|
||||||
|
if (pack == null) {
|
||||||
|
throw new IOException("Unknown asset pack: " + packName);
|
||||||
|
}
|
||||||
|
if (pack.isImmutable()) {
|
||||||
|
throw new IOException("Asset pack is immutable: " + pack.getName());
|
||||||
|
}
|
||||||
|
return pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps an already-persisted definition at its existing file location within the target pack. */
|
||||||
|
private static Path relativePathInPack(AssetPack pack, String id) {
|
||||||
|
Path storeRoot = pack.getRoot().resolve("Server").resolve("Minigames");
|
||||||
|
Path existing = MinigameDefinition.getAssetMap().getPath(id);
|
||||||
|
if (existing != null && existing.startsWith(storeRoot)) {
|
||||||
|
return storeRoot.relativize(existing);
|
||||||
|
}
|
||||||
|
return Path.of(id + ".json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package net.kewwbec.minigames.persistence;
|
|
||||||
|
|
||||||
import com.hypixel.hytale.assetstore.AssetExtraInfo;
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
|
|
||||||
public final class LocalJsonMinigameDefinitionStore implements MinigameDefinitionStore {
|
|
||||||
private static final String PACK_KEY = "net.kewwbec:core-minigames";
|
|
||||||
|
|
||||||
private final Path minigameDirectory;
|
|
||||||
|
|
||||||
public LocalJsonMinigameDefinitionStore(Path dataRoot) {
|
|
||||||
this.minigameDirectory = dataRoot.resolve("minigames");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void save(MinigameDefinition definition) throws IOException {
|
|
||||||
Files.createDirectories(minigameDirectory);
|
|
||||||
Path filePath = minigameDirectory.resolve(definition.getId() + ".json");
|
|
||||||
var extraInfo = new AssetExtraInfo<>(
|
|
||||||
filePath,
|
|
||||||
new AssetExtraInfo.Data(MinigameDefinition.class, definition.getId(), null)
|
|
||||||
);
|
|
||||||
String json = MinigameDefinition.CODEC.encode(definition, extraInfo).toString();
|
|
||||||
Files.writeString(filePath, json, StandardCharsets.UTF_8);
|
|
||||||
MinigameDefinition.getAssetStore().loadAssetsFromPaths(PACK_KEY, java.util.List.of(filePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void delete(String id) throws IOException {
|
|
||||||
Path filePath = minigameDirectory.resolve(id + ".json");
|
|
||||||
Files.deleteIfExists(filePath);
|
|
||||||
MinigameDefinition.getAssetStore().removeAssets(java.util.Set.of(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,11 @@ import net.kewwbec.minigames.model.MinigameDefinition;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
public interface MinigameDefinitionStore {
|
public interface MinigameDefinitionStore {
|
||||||
|
/** Writes the definition into the named asset pack and (re)loads it into the asset store. */
|
||||||
|
void save(String packName, MinigameDefinition definition) throws IOException;
|
||||||
|
|
||||||
|
/** Saves the definition back into the asset pack it currently belongs to. */
|
||||||
void save(MinigameDefinition definition) throws IOException;
|
void save(MinigameDefinition definition) throws IOException;
|
||||||
|
|
||||||
void delete(String id) throws IOException;
|
void delete(String id) throws IOException;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,20 @@ import com.hypixel.hytale.server.core.HytaleServer;
|
|||||||
import com.hypixel.hytale.server.core.Message;
|
import com.hypixel.hytale.server.core.Message;
|
||||||
import com.hypixel.hytale.logger.HytaleLogger;
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
||||||
import net.kewwbec.minigames.model.ItemStackConfig;
|
|
||||||
import net.kewwbec.minigames.model.LoadoutConfig;
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.service.MinigameQueueService;
|
|
||||||
import net.kewwbec.minigames.ui.MinigameHudService;
|
|
||||||
import net.kewwbec.minigames.ui.MinigameMenuService;
|
|
||||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
import net.kewwbec.minigames.model.RewardConfig;
|
import net.kewwbec.minigames.model.RewardConfig;
|
||||||
|
import net.kewwbec.minigames.model.ItemStackConfig;
|
||||||
|
import net.kewwbec.minigames.model.TeamState;
|
||||||
|
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||||
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
import net.kewwbec.minigames.service.MinigameVolumeSignalService;
|
||||||
|
import net.kewwbec.minigames.ui.MinigameHudService;
|
||||||
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -26,14 +25,24 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||||
|
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
import static net.kewwbec.minigames.model.Enums.TeamMode;
|
||||||
import static net.kewwbec.minigames.model.Enums.WinCondition;
|
import static net.kewwbec.minigames.model.Enums.WinCondition;
|
||||||
|
|
||||||
public final class DefaultMinigameRuntimeService implements MinigameRuntimeService {
|
public final class DefaultMinigameRuntimeService implements MinigameRuntimeService {
|
||||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
private final Map<String, MinigameRuntime> runtimes = new HashMap<>();
|
static final String TIMER_ROUND = "$round";
|
||||||
|
static final String TIMER_OVERTIME = "$overtime";
|
||||||
|
static final String TIMER_SUDDEN_DEATH = "$suddendeath";
|
||||||
|
|
||||||
|
private final Map<String, MinigameRuntime> runtimes = new ConcurrentHashMap<>();
|
||||||
|
// Re-entrancy guard so end() running for a runtime cannot be triggered twice
|
||||||
|
// (e.g. an on_game_end listener firing an EndMinigame volume).
|
||||||
|
private final Set<String> endingRuntimes = ConcurrentHashMap.newKeySet();
|
||||||
@Nullable
|
@Nullable
|
||||||
private MinigameVolumeSignalService signalService;
|
private MinigameVolumeSignalService signalService;
|
||||||
@Nullable
|
@Nullable
|
||||||
@@ -45,14 +54,8 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
@Nullable
|
@Nullable
|
||||||
private HytaleAdapters.InventoryAdapter inventoryAdapter;
|
private HytaleAdapters.InventoryAdapter inventoryAdapter;
|
||||||
@Nullable
|
@Nullable
|
||||||
private MinigameMenuService menuService;
|
|
||||||
@Nullable
|
|
||||||
private MinigameHudService hudService;
|
private MinigameHudService hudService;
|
||||||
|
|
||||||
public void setMenuService(@Nullable MinigameMenuService menuService) {
|
|
||||||
this.menuService = menuService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setHudService(@Nullable MinigameHudService hudService) {
|
public void setHudService(@Nullable MinigameHudService hudService) {
|
||||||
this.hudService = hudService;
|
this.hudService = hudService;
|
||||||
}
|
}
|
||||||
@@ -79,14 +82,28 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MinigameRuntime start(MinigameDefinition definition) {
|
public MinigameRuntime start(MinigameDefinition definition) {
|
||||||
return start(definition, MinigameMapCandidate.none(definition.getId()));
|
return start(definition, MinigameMapCandidate.none(definition.getId()), null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map) {
|
public MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map) {
|
||||||
|
return start(definition, map, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map,
|
||||||
|
@Nullable String worldName, boolean adminStarted) {
|
||||||
var selected = map != null ? map : MinigameMapCandidate.none(definition.getId());
|
var selected = map != null ? map : MinigameMapCandidate.none(definition.getId());
|
||||||
var runtime = new MinigameRuntime(UUID.randomUUID().toString(), definition, selected.mapId(), selected.variantId());
|
var runtime = new MinigameRuntime(UUID.randomUUID().toString(), definition, selected.mapId(), selected.variantId());
|
||||||
runtime.phase(MinigamePhase.WAITING_FOR_PLAYERS);
|
runtime.worldName(worldName);
|
||||||
|
runtime.adminStarted(adminStarted);
|
||||||
|
|
||||||
|
String requiredWorld = definition.getWorldId();
|
||||||
|
if (!requiredWorld.isBlank() && worldName != null && !worldName.isBlank() && !requiredWorld.equals(worldName)) {
|
||||||
|
LOGGER.atWarning().log("Minigame %s is restricted to world '%s' but was started in '%s'",
|
||||||
|
definition.getId(), requiredWorld, worldName);
|
||||||
|
}
|
||||||
|
|
||||||
runtimes.put(runtime.runtimeId(), runtime);
|
runtimes.put(runtime.runtimeId(), runtime);
|
||||||
|
|
||||||
int countdownSecs = Math.max(0, definition.getCountdownSeconds());
|
int countdownSecs = Math.max(0, definition.getCountdownSeconds());
|
||||||
@@ -94,23 +111,81 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
runtime.variables().put("$countdownEnd", countdownEndMs);
|
runtime.variables().put("$countdownEnd", countdownEndMs);
|
||||||
runtime.variables().put("$countdownNext", (long) countdownSecs);
|
runtime.variables().put("$countdownNext", (long) countdownSecs);
|
||||||
|
|
||||||
if (signalService != null) {
|
setPhase(runtime, MinigamePhase.WAITING_FOR_PLAYERS);
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.WAITING_FOR_PLAYERS);
|
|
||||||
}
|
|
||||||
dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime)));
|
dispatch(new MinigameEvent("on_game_start", definition.getId(), eventPayload(runtime)));
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
/** Auto-creates and balances teams when the definition uses TeamMode.TEAMS. Call after players are added. */
|
||||||
public void tickPregame(long nowMs) {
|
public void setupTeams(MinigameRuntime runtime) {
|
||||||
for (MinigameRuntime runtime : List.copyOf(runtimes.values())) {
|
if (runtime.definition().getTeamMode() != TeamMode.TEAMS) {
|
||||||
if (runtime.phase() != MinigamePhase.WAITING_FOR_PLAYERS) continue;
|
return;
|
||||||
|
}
|
||||||
|
List<TeamState> teams = ensureTeams(runtime);
|
||||||
|
if (teams.size() < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (TeamState team : teams) {
|
||||||
|
team.players().clear();
|
||||||
|
}
|
||||||
|
List<String> players = runtime.players().keySet().stream().sorted().toList();
|
||||||
|
for (int i = 0; i < players.size(); i++) {
|
||||||
|
String playerId = players.get(i);
|
||||||
|
TeamState team = teams.get(i % teams.size());
|
||||||
|
runtime.players().get(playerId).teamId(team.teamId());
|
||||||
|
team.players().add(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel if player count has dropped below minimum (leave pad or disconnect)
|
private static List<TeamState> ensureTeams(MinigameRuntime runtime) {
|
||||||
int present = countPresentPlayers(runtime);
|
List<TeamState> teams = new ArrayList<>(runtime.teams().values());
|
||||||
if (present < runtime.definition().getMinPlayers()) {
|
if (teams.isEmpty()) {
|
||||||
cancelPregame(runtime, "player_count_too_low");
|
int count = Math.max(0, runtime.definition().getTeamCount());
|
||||||
continue;
|
if (count == 0) {
|
||||||
|
int perTeam = runtime.definition().getPlayersPerTeam();
|
||||||
|
if (perTeam > 0) {
|
||||||
|
count = (int) Math.ceil((double) runtime.players().size() / perTeam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 1; i <= count; i++) {
|
||||||
|
String id = "team" + i;
|
||||||
|
teams.add(runtime.teams().computeIfAbsent(id, key -> new TeamState(key, key, null)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
teams.sort(java.util.Comparator.comparing(TeamState::teamId));
|
||||||
|
return teams;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setPhase(MinigameRuntime runtime, MinigamePhase phase) {
|
||||||
|
setPhase(runtime, phase, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setPhase(MinigameRuntime runtime, MinigamePhase phase, boolean allowLoops) {
|
||||||
|
runtime.phase(phase);
|
||||||
|
if (signalService != null) {
|
||||||
|
signalService.onPhaseChange(runtime, phase, allowLoops);
|
||||||
|
}
|
||||||
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
|
payload.put("phase", phase.name());
|
||||||
|
dispatch(new MinigameEvent("on_phase_change", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void tickPregame(long nowMs, String worldName) {
|
||||||
|
for (MinigameRuntime runtime : runtimes.values()) {
|
||||||
|
if (runtime.phase() != MinigamePhase.WAITING_FOR_PLAYERS) continue;
|
||||||
|
// Home-world confinement: only the owning world's thread may mutate the runtime.
|
||||||
|
if (!runtime.adoptWorld(worldName)) continue;
|
||||||
|
|
||||||
|
// Cancel if player count has dropped below minimum (leave pad or disconnect).
|
||||||
|
// Admin-started games are exempt from the min-player check.
|
||||||
|
if (!runtime.adminStarted()) {
|
||||||
|
int present = countPresentPlayers(runtime);
|
||||||
|
if (present < runtime.definition().getMinPlayers()) {
|
||||||
|
cancelPregame(runtime, "player_count_too_low");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object endObj = runtime.variables().get("$countdownEnd");
|
Object endObj = runtime.variables().get("$countdownEnd");
|
||||||
@@ -126,7 +201,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
if (remainingSecs < nextTick) {
|
if (remainingSecs < nextTick) {
|
||||||
runtime.variables().put("$countdownNext", remainingSecs);
|
runtime.variables().put("$countdownNext", remainingSecs);
|
||||||
var payload = new HashMap<>(eventPayload(runtime));
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
payload.put("seconds_remaining", remainingSecs);
|
payload.put("seconds", remainingSecs);
|
||||||
dispatch(new MinigameEvent("on_countdown_tick", runtime.minigameId(), Map.copyOf(payload)));
|
dispatch(new MinigameEvent("on_countdown_tick", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
if (playerAdapter != null && remainingSecs > 0) {
|
if (playerAdapter != null && remainingSecs > 0) {
|
||||||
Message msg = Message.translation("minigames.countdown.tick").param("seconds", remainingSecs);
|
Message msg = Message.translation("minigames.countdown.tick").param("seconds", remainingSecs);
|
||||||
@@ -145,10 +220,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void advanceToStarting(MinigameRuntime runtime) {
|
private void advanceToStarting(MinigameRuntime runtime) {
|
||||||
runtime.phase(MinigamePhase.STARTING);
|
setPhase(runtime, MinigamePhase.STARTING);
|
||||||
if (signalService != null) {
|
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.STARTING);
|
|
||||||
}
|
|
||||||
dispatch(new MinigameEvent("on_game_starting", runtime.minigameId(), eventPayload(runtime)));
|
dispatch(new MinigameEvent("on_game_starting", runtime.minigameId(), eventPayload(runtime)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,124 +237,260 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
if (queueService != null) {
|
if (queueService != null) {
|
||||||
for (String playerId : runtime.players().keySet()) {
|
for (String playerId : runtime.players().keySet()) {
|
||||||
if (playerAdapter == null || playerAdapter.isOnline(playerId)) {
|
if (playerAdapter == null || playerAdapter.isOnline(playerId)) {
|
||||||
queueService.join(runtime.minigameId(), playerId);
|
queueService.join(runtime.definition(), playerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var payload = new HashMap<>(eventPayload(runtime));
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
payload.put("reason", reason);
|
payload.put("reason", reason);
|
||||||
dispatch(new MinigameEvent("on_game_cancelled", runtime.minigameId(), Map.copyOf(payload)));
|
dispatch(new MinigameEvent("on_game_cancelled", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
|
if (hudService != null) {
|
||||||
|
hudService.removeAllForRuntime(runtime.runtimeId());
|
||||||
|
}
|
||||||
|
if (signalService != null) {
|
||||||
|
signalService.cancelAll(runtime.runtimeId());
|
||||||
|
}
|
||||||
clearRuntimeState(runtime);
|
clearRuntimeState(runtime);
|
||||||
runtimes.remove(runtime.runtimeId());
|
runtimes.remove(runtime.runtimeId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private int countPresentPlayers(MinigameRuntime runtime) {
|
private int countPresentPlayers(MinigameRuntime runtime) {
|
||||||
if (playerAdapter == null) return runtime.players().size();
|
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (String playerId : runtime.players().keySet()) {
|
for (var entry : runtime.players().entrySet()) {
|
||||||
if (playerAdapter.isOnline(playerId)) count++;
|
if (entry.getValue().status() == PlayerStatus.LEFT) continue;
|
||||||
|
if (playerAdapter == null || playerAdapter.isOnline(entry.getKey())) count++;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void end(String minigameId, String reason) {
|
public void endRuntime(String runtimeId, String reason) {
|
||||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
MinigameRuntime runtime = runtimes.get(runtimeId);
|
||||||
|
if (runtime != null) {
|
||||||
|
doEnd(runtime, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void endAll(String minigameId, String reason) {
|
||||||
|
for (MinigameRuntime runtime : runtimesForMinigame(minigameId)) {
|
||||||
|
doEnd(runtime, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doEnd(MinigameRuntime runtime, String reason) {
|
||||||
|
if (!endingRuntimes.add(runtime.runtimeId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
if (hudService != null) {
|
if (hudService != null) {
|
||||||
hudService.removeAllForRuntime(runtime.runtimeId());
|
hudService.removeAllForRuntime(runtime.runtimeId());
|
||||||
}
|
}
|
||||||
runtime.phase(MinigamePhase.ENDING);
|
// ENDING signals fire while the players map is still intact so volumes have an actor.
|
||||||
if (signalService != null) {
|
setPhase(runtime, MinigamePhase.ENDING);
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.ENDING);
|
|
||||||
signalService.cancelAll(runtime.runtimeId());
|
|
||||||
}
|
|
||||||
cleanupTemporaryEntities(runtime);
|
cleanupTemporaryEntities(runtime);
|
||||||
if (runtime.definition().isRestorePlayerInventory()) {
|
if (runtime.definition().isRestorePlayerInventory()) {
|
||||||
restoreAllInventories(runtime);
|
restoreAllInventories(runtime);
|
||||||
}
|
}
|
||||||
giveRewards(runtime);
|
giveRewards(runtime);
|
||||||
|
sendEndScores(runtime);
|
||||||
var payload = new HashMap<String, Object>(eventPayload(runtime));
|
var payload = new HashMap<String, Object>(eventPayload(runtime));
|
||||||
payload.put("reason", reason);
|
payload.put("reason", reason);
|
||||||
dispatch(new MinigameEvent("on_game_end", runtime.minigameId(), payload));
|
dispatch(new MinigameEvent("on_game_end", runtime.minigameId(), payload));
|
||||||
clearRuntimeState(runtime);
|
|
||||||
runtimes.remove(runtime.runtimeId());
|
|
||||||
if (runtime.definition().isResetOnEnd()) {
|
if (runtime.definition().isResetOnEnd()) {
|
||||||
if (signalService != null) {
|
// One-shot only: the runtime is about to be removed, so tick loops registered
|
||||||
runtime.phase(MinigamePhase.RESETTING);
|
// for RESETTING would never be cancelled (they used to leak forever).
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
|
setPhase(runtime, MinigamePhase.RESETTING, false);
|
||||||
}
|
|
||||||
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (signalService != null) {
|
||||||
|
signalService.cancelAll(runtime.runtimeId());
|
||||||
|
}
|
||||||
|
clearRuntimeState(runtime);
|
||||||
|
runtimes.remove(runtime.runtimeId());
|
||||||
|
} finally {
|
||||||
|
endingRuntimes.remove(runtime.runtimeId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetRuntime(String runtimeId) {
|
||||||
|
MinigameRuntime runtime = runtimes.get(runtimeId);
|
||||||
|
if (runtime != null) {
|
||||||
|
doReset(runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void reset(String minigameId) {
|
public void reset(String minigameId) {
|
||||||
for (MinigameRuntime runtime : matching(minigameId)) {
|
for (MinigameRuntime runtime : runtimesForMinigame(minigameId)) {
|
||||||
runtime.phase(MinigamePhase.RESETTING);
|
doReset(runtime);
|
||||||
if (signalService != null) {
|
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.RESETTING);
|
|
||||||
signalService.cancelAll(runtime.runtimeId());
|
|
||||||
}
|
|
||||||
clearRuntimeState(runtime);
|
|
||||||
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void doReset(MinigameRuntime runtime) {
|
||||||
|
if (signalService != null) {
|
||||||
|
signalService.cancelAll(runtime.runtimeId());
|
||||||
|
}
|
||||||
|
// Players are still present here so RESETTING volume signals can fire.
|
||||||
|
setPhase(runtime, MinigamePhase.RESETTING);
|
||||||
|
dispatch(new MinigameEvent("on_arena_reset", runtime.minigameId(), eventPayload(runtime)));
|
||||||
|
clearRuntimeState(runtime);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
public void onPlayerActivated(MinigameRuntime runtime, String playerId) {
|
||||||
var def = runtime.definition();
|
var def = runtime.definition();
|
||||||
|
|
||||||
|
// Inventory policy: snapshot the player's inventory once, clear it for the game,
|
||||||
|
// and restore it when the game ends. Items are only granted by volume effects
|
||||||
|
// (SetPlayerLoadout / SpawnItem) — never automatically.
|
||||||
if (inventoryAdapter != null && def.isSavePlayerInventory()) {
|
if (inventoryAdapter != null && def.isSavePlayerInventory()) {
|
||||||
var playerState = runtime.players().get(playerId);
|
var playerState = runtime.players().get(playerId);
|
||||||
if (playerState != null && playerState.savedInventory().isEmpty()) {
|
if (playerState != null && playerState.savedInventory().isEmpty()) {
|
||||||
var snapshot = inventoryAdapter.saveInventory(playerId);
|
var snapshot = inventoryAdapter.saveInventory(playerId);
|
||||||
playerState.savedInventory().putAll(snapshot);
|
playerState.savedInventory().putAll(snapshot);
|
||||||
|
inventoryAdapter.clearInventory(playerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerAdapter != null) {
|
if (playerAdapter != null) {
|
||||||
var playerState = runtime.players().get(playerId);
|
// A reactivated spectator must lose the spectator presentation before becoming playable.
|
||||||
LoadoutConfig loadout = resolveLoadout(runtime, playerState);
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
if (loadout != null) {
|
|
||||||
if (loadout.isPlayerCustomizable()) {
|
|
||||||
if (menuService != null) {
|
|
||||||
menuService.requestLoadoutSelection(playerId, runtime);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (ItemStackConfig item : loadout.getItems()) {
|
|
||||||
if (item != null) {
|
|
||||||
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (ItemStackConfig item : def.getStartItems()) {
|
|
||||||
if (item != null) {
|
|
||||||
playerAdapter.giveItem(playerId, item.getItemId(), item.getAmount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
playerAdapter.setGameMode(playerId, def.getRequiredGameMode());
|
||||||
}
|
}
|
||||||
LOGGER.atInfo().log("[MinigameHUD] onPlayerActivated: player=%s minigame=%s showHud=%s hudService=%s",
|
|
||||||
playerId, runtime.minigameId(), def.isShowHud(), hudService != null ? "set" : "NULL");
|
|
||||||
if (def.isShowHud() && hudService != null) {
|
if (def.isShowHud() && hudService != null) {
|
||||||
hudService.requestHudShow(playerId, runtime);
|
hudService.requestHudShow(playerId, runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<MinigameRuntime> runtime(String id) {
|
public void onPlayerSpectate(MinigameRuntime runtime, String playerId) {
|
||||||
MinigameRuntime byRuntimeId = runtimes.get(id);
|
var state = runtime.players().computeIfAbsent(playerId,
|
||||||
if (byRuntimeId != null) {
|
ignored -> new PlayerMinigameState(playerId, runtime.minigameId()));
|
||||||
return Optional.of(byRuntimeId);
|
state.status(PlayerStatus.SPECTATOR);
|
||||||
|
runtime.spectators().add(playerId);
|
||||||
|
runtime.eliminatedPlayers().remove(playerId);
|
||||||
|
if (playerAdapter != null) {
|
||||||
|
playerAdapter.applySpectatorState(playerId);
|
||||||
}
|
}
|
||||||
return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(id)).findFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<MinigameRuntime> runtimes(String minigameId) {
|
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
|
||||||
|
public void removePlayer(MinigameRuntime runtime, String playerId, String reason) {
|
||||||
|
var player = runtime.players().get(playerId);
|
||||||
|
if (player == null || player.status() == PlayerStatus.LEFT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean wasSpectator = runtime.spectators().contains(playerId);
|
||||||
|
player.status(PlayerStatus.LEFT);
|
||||||
|
if (player.teamId() != null) {
|
||||||
|
var team = runtime.teams().get(player.teamId());
|
||||||
|
if (team != null) {
|
||||||
|
team.players().remove(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.spectators().remove(playerId);
|
||||||
|
if (wasSpectator && playerAdapter != null) {
|
||||||
|
playerAdapter.clearSpectatorState(playerId);
|
||||||
|
}
|
||||||
|
if (hudService != null) {
|
||||||
|
hudService.removeForPlayer(playerId);
|
||||||
|
}
|
||||||
|
if (inventoryAdapter != null && !player.savedInventory().isEmpty()
|
||||||
|
&& (playerAdapter == null || playerAdapter.isOnline(playerId))) {
|
||||||
|
inventoryAdapter.restoreInventory(playerId, player.savedInventory());
|
||||||
|
player.savedInventory().clear();
|
||||||
|
}
|
||||||
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
|
payload.put("player_id", playerId);
|
||||||
|
payload.put("reason", reason);
|
||||||
|
dispatch(new MinigameEvent("on_player_left", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
|
|
||||||
|
boolean anyoneLeft = runtime.players().values().stream()
|
||||||
|
.anyMatch(p -> p.status() != PlayerStatus.LEFT && p.status() != PlayerStatus.SPECTATOR);
|
||||||
|
if (!anyoneLeft && runtime.phase() != MinigamePhase.ENDING && runtime.phase() != MinigamePhase.RESETTING) {
|
||||||
|
endRuntime(runtime.runtimeId(), "all_players_left");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerReconnected(MinigameRuntime runtime, String playerId) {
|
||||||
|
var player = runtime.players().get(playerId);
|
||||||
|
if (player == null || player.status() != PlayerStatus.LEFT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.definition().isAllowJoinMidgame()) {
|
||||||
|
player.status(PlayerStatus.ACTIVE);
|
||||||
|
var payload = new HashMap<>(eventPayload(runtime));
|
||||||
|
payload.put("player_id", playerId);
|
||||||
|
dispatch(new MinigameEvent("on_player_rejoined", runtime.minigameId(), Map.copyOf(payload)));
|
||||||
|
onPlayerActivated(runtime, playerId);
|
||||||
|
} else {
|
||||||
|
// The game moved on without them: give their inventory back and drop them.
|
||||||
|
if (inventoryAdapter != null && !player.savedInventory().isEmpty()) {
|
||||||
|
inventoryAdapter.restoreInventory(playerId, player.savedInventory());
|
||||||
|
}
|
||||||
|
runtime.players().remove(playerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<MinigameRuntime> runtimeById(String runtimeId) {
|
||||||
|
return Optional.ofNullable(runtimes.get(runtimeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<MinigameRuntime> runtimesForMinigame(String minigameId) {
|
||||||
return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(minigameId)).toList();
|
return runtimes.values().stream().filter(runtime -> runtime.minigameId().equals(minigameId)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,20 +521,32 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
@Override
|
@Override
|
||||||
public void shutdownAll(String reason) {
|
public void shutdownAll(String reason) {
|
||||||
for (String runtimeId : runtimes.keySet().toArray(String[]::new)) {
|
for (String runtimeId : runtimes.keySet().toArray(String[]::new)) {
|
||||||
end(runtimeId, reason);
|
endRuntime(runtimeId, reason);
|
||||||
}
|
}
|
||||||
runtimes.clear();
|
runtimes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload) {
|
public void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload) {
|
||||||
// Internal round timer — handle auto-progression, do not propagate as a public event
|
// Internal timers — handle auto-progression, do not propagate as public events
|
||||||
if ("on_timer_expired".equals(eventId)) {
|
if ("on_timer_expired".equals(eventId)) {
|
||||||
Object timerName = payload != null ? payload.get("timer_name") : null;
|
Object timerName = payload != null ? payload.get("timer_name") : null;
|
||||||
if ("$round".equals(timerName)) {
|
if (TIMER_ROUND.equals(timerName)) {
|
||||||
handleRoundTimerExpiry(runtime);
|
handleRoundTimerExpiry(runtime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (TIMER_OVERTIME.equals(timerName)) {
|
||||||
|
if (runtime.definition().isSuddenDeathEnabled()) {
|
||||||
|
enterSuddenDeath(runtime);
|
||||||
|
} else {
|
||||||
|
endRuntime(runtime.runtimeId(), "overtime_complete");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TIMER_SUDDEN_DEATH.equals(timerName)) {
|
||||||
|
endRuntime(runtime.runtimeId(), "sudden_death_complete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var full = new HashMap<String, Object>(eventPayload(runtime));
|
var full = new HashMap<String, Object>(eventPayload(runtime));
|
||||||
if (payload != null) full.putAll(payload);
|
if (payload != null) full.putAll(payload);
|
||||||
@@ -347,30 +567,17 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
dispatch(new MinigameEvent("on_round_end", runtime.minigameId(), Map.copyOf(endPayload)));
|
dispatch(new MinigameEvent("on_round_end", runtime.minigameId(), Map.copyOf(endPayload)));
|
||||||
|
|
||||||
if (total > 0 && current >= total) {
|
if (total > 0 && current >= total) {
|
||||||
sendEndScores(runtime);
|
|
||||||
|
|
||||||
if (runtime.definition().isOvertimeEnabled()) {
|
|
||||||
runtime.phase(MinigamePhase.OVERTIME);
|
|
||||||
if (signalService != null) {
|
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.OVERTIME);
|
|
||||||
}
|
|
||||||
dispatch(new MinigameEvent("on_overtime_start", runtime.minigameId(), Map.copyOf(eventPayload(runtime))));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime.definition().isSuddenDeathEnabled()) {
|
|
||||||
runtime.phase(MinigamePhase.SUDDEN_DEATH);
|
|
||||||
if (signalService != null) {
|
|
||||||
signalService.onPhaseChange(runtime, MinigamePhase.SUDDEN_DEATH);
|
|
||||||
}
|
|
||||||
dispatch(new MinigameEvent("on_sudden_death_start", runtime.minigameId(), Map.copyOf(eventPayload(runtime))));
|
|
||||||
}
|
|
||||||
|
|
||||||
var donePayload = new HashMap<>(eventPayload(runtime));
|
var donePayload = new HashMap<>(eventPayload(runtime));
|
||||||
donePayload.put("final_round", current);
|
donePayload.put("final_round", current);
|
||||||
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
|
dispatch(new MinigameEvent("on_game_rounds_complete", runtime.minigameId(), Map.copyOf(donePayload)));
|
||||||
|
|
||||||
if (!runtime.definition().isOvertimeEnabled() && !runtime.definition().isSuddenDeathEnabled()) {
|
// Sequential extra phases: overtime first (if enabled), then sudden death.
|
||||||
end(runtime.minigameId(), "rounds_complete");
|
if (runtime.definition().isOvertimeEnabled()) {
|
||||||
|
enterOvertime(runtime);
|
||||||
|
} else if (runtime.definition().isSuddenDeathEnabled()) {
|
||||||
|
enterSuddenDeath(runtime);
|
||||||
|
} else {
|
||||||
|
endRuntime(runtime.runtimeId(), "rounds_complete");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
int next = current + 1;
|
int next = current + 1;
|
||||||
@@ -381,7 +588,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
signalService.onRoundStart(runtime, next);
|
signalService.onRoundStart(runtime, next);
|
||||||
int roundLen = runtime.definition().getRoundLengthSeconds();
|
int roundLen = runtime.definition().getRoundLengthSeconds();
|
||||||
if (roundLen > 0) {
|
if (roundLen > 0) {
|
||||||
signalService.startTimer(runtime, "$round", roundLen * 1000L);
|
signalService.startTimer(runtime, TIMER_ROUND, roundLen * 1000L);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,12 +598,22 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Collection<MinigameRuntime> matching(String id) {
|
private void enterOvertime(MinigameRuntime runtime) {
|
||||||
MinigameRuntime runtime = runtimes.get(id);
|
setPhase(runtime, MinigamePhase.OVERTIME);
|
||||||
if (runtime != null) {
|
dispatch(new MinigameEvent("on_overtime_start", runtime.minigameId(), eventPayload(runtime)));
|
||||||
return java.util.List.of(runtime);
|
if (signalService != null) {
|
||||||
|
int secs = Math.max(1, runtime.definition().getOvertimeSeconds());
|
||||||
|
signalService.startTimer(runtime, TIMER_OVERTIME, secs * 1000L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enterSuddenDeath(MinigameRuntime runtime) {
|
||||||
|
setPhase(runtime, MinigamePhase.SUDDEN_DEATH);
|
||||||
|
dispatch(new MinigameEvent("on_sudden_death_start", runtime.minigameId(), eventPayload(runtime)));
|
||||||
|
if (signalService != null) {
|
||||||
|
int secs = Math.max(1, runtime.definition().getSuddenDeathSeconds());
|
||||||
|
signalService.startTimer(runtime, TIMER_SUDDEN_DEATH, secs * 1000L);
|
||||||
}
|
}
|
||||||
return runtimes(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanupTemporaryEntities(MinigameRuntime runtime) {
|
private void cleanupTemporaryEntities(MinigameRuntime runtime) {
|
||||||
@@ -410,8 +627,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityIds = java.util.List.copyOf(runtime.temporaryEntities());
|
var entityIds = List.copyOf(runtime.temporaryEntities());
|
||||||
LOGGER.atInfo().log("Cleaning up %s temporary entities for runtime=%s minigame=%s", entityIds.size(), runtime.runtimeId(), runtime.minigameId());
|
|
||||||
for (String entityId : entityIds) {
|
for (String entityId : entityIds) {
|
||||||
entityAdapter.despawn(entityId);
|
entityAdapter.despawn(entityId);
|
||||||
}
|
}
|
||||||
@@ -422,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();
|
||||||
@@ -447,14 +669,13 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
if (playerAdapter == null || runtime.players().isEmpty()) return;
|
||||||
|
|
||||||
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
|
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
|
||||||
List<String> ranked = buildRankedPlayers(runtime);
|
List<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||||
|
|
||||||
var recipients = runtime.players().keySet();
|
for (String recipient : runtime.players().keySet()) {
|
||||||
for (String recipient : recipients) {
|
|
||||||
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
|
playerAdapter.sendMessage(recipient, Message.translation("minigames.endscores.header"));
|
||||||
int rank = 1;
|
int rank = 1;
|
||||||
for (String playerId : ranked) {
|
for (String playerId : ranked) {
|
||||||
var state = runtime.players().get(playerId);
|
PlayerMinigameState state = runtime.players().get(playerId);
|
||||||
if (state == null) continue;
|
if (state == null) continue;
|
||||||
String name = playerAdapter.getDisplayName(playerId);
|
String name = playerAdapter.getDisplayName(playerId);
|
||||||
String scoreText = lowestTime
|
String scoreText = lowestTime
|
||||||
@@ -485,7 +706,7 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
RewardConfig[] rewards = runtime.definition().getRewards();
|
RewardConfig[] rewards = runtime.definition().getRewards();
|
||||||
if (rewards == null || rewards.length == 0) return;
|
if (rewards == null || rewards.length == 0) return;
|
||||||
|
|
||||||
List<String> ranked = buildRankedPlayers(runtime);
|
List<String> ranked = Rankings.rankedPlayerIds(runtime, false);
|
||||||
for (RewardConfig reward : rewards) {
|
for (RewardConfig reward : rewards) {
|
||||||
if (reward == null || reward.getTarget() == null) continue;
|
if (reward == null || reward.getTarget() == null) continue;
|
||||||
Set<String> eligible = resolveRewardTargets(reward.getTarget(), ranked);
|
Set<String> eligible = resolveRewardTargets(reward.getTarget(), ranked);
|
||||||
@@ -499,41 +720,6 @@ public final class DefaultMinigameRuntimeService implements MinigameRuntimeServi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private static LoadoutConfig resolveLoadout(MinigameRuntime runtime, @Nullable PlayerMinigameState playerState) {
|
|
||||||
if (playerState == null || playerState.loadoutId() == null) return null;
|
|
||||||
for (LoadoutConfig loadout : runtime.definition().getStartLoadouts()) {
|
|
||||||
if (loadout != null && loadout.getId().equals(playerState.loadoutId())) {
|
|
||||||
return loadout;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<String> buildRankedPlayers(MinigameRuntime runtime) {
|
|
||||||
WinCondition wc = runtime.definition().getWinCondition();
|
|
||||||
if (wc == WinCondition.LOWEST_TIME) {
|
|
||||||
return runtime.players().entrySet().stream()
|
|
||||||
.sorted((a, b) -> {
|
|
||||||
int sa = a.getValue().score();
|
|
||||||
int sb = b.getValue().score();
|
|
||||||
if (sa == 0 && sb == 0) return 0;
|
|
||||||
if (sa == 0) return 1;
|
|
||||||
if (sb == 0) return -1;
|
|
||||||
return Integer.compare(sa, sb);
|
|
||||||
})
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
|
|
||||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
|
|
||||||
return runtime.players().entrySet().stream()
|
|
||||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
|
||||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<String> resolveRewardTargets(String target, List<String> ranked) {
|
private static Set<String> resolveRewardTargets(String target, List<String> ranked) {
|
||||||
String lower = target.trim().toLowerCase();
|
String lower = target.trim().toLowerCase();
|
||||||
if ("all".equals(lower)) {
|
if ("all".equals(lower)) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import java.util.HashMap;
|
|||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ public final class MinigameRuntime {
|
|||||||
private final String minigameId;
|
private final String minigameId;
|
||||||
private final String mapId;
|
private final String mapId;
|
||||||
private final String variantId;
|
private final String variantId;
|
||||||
|
// Home world: all mutation of this runtime must happen on this world's thread.
|
||||||
|
private final AtomicReference<String> worldName = new AtomicReference<>("");
|
||||||
|
private boolean adminStarted;
|
||||||
private MinigamePhase phase = MinigamePhase.CREATED;
|
private MinigamePhase phase = MinigamePhase.CREATED;
|
||||||
private int roundNumber = 1;
|
private int roundNumber = 1;
|
||||||
private final Instant createdAt = Instant.now();
|
private final Instant createdAt = Instant.now();
|
||||||
@@ -50,6 +54,15 @@ public final class MinigameRuntime {
|
|||||||
public String minigameId() { return minigameId; }
|
public String minigameId() { return minigameId; }
|
||||||
public String mapId() { return mapId; }
|
public String mapId() { return mapId; }
|
||||||
public String variantId() { return variantId; }
|
public String variantId() { return variantId; }
|
||||||
|
public String worldName() { return worldName.get(); }
|
||||||
|
public void worldName(String worldName) { this.worldName.set(worldName != null ? worldName : ""); }
|
||||||
|
/** Claims this runtime for a world if no home world is set yet. Returns true if this world now owns it. */
|
||||||
|
public boolean adoptWorld(String worldName) {
|
||||||
|
if (worldName == null || worldName.isBlank()) return false;
|
||||||
|
return this.worldName.compareAndSet("", worldName) || worldName.equals(this.worldName.get());
|
||||||
|
}
|
||||||
|
public boolean adminStarted() { return adminStarted; }
|
||||||
|
public void adminStarted(boolean adminStarted) { this.adminStarted = adminStarted; }
|
||||||
public MinigamePhase phase() { return phase; }
|
public MinigamePhase phase() { return phase; }
|
||||||
public void phase(MinigamePhase phase) { this.phase = phase; }
|
public void phase(MinigamePhase phase) { this.phase = phase; }
|
||||||
public int roundNumber() { return roundNumber; }
|
public int roundNumber() { return roundNumber; }
|
||||||
|
|||||||
@@ -2,23 +2,85 @@ 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 java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||||
|
|
||||||
public interface MinigameRuntimeService {
|
public interface MinigameRuntimeService {
|
||||||
MinigameRuntime start(MinigameDefinition definition);
|
MinigameRuntime start(MinigameDefinition definition);
|
||||||
|
|
||||||
MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map);
|
MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map);
|
||||||
void end(String id, String reason);
|
|
||||||
void reset(String id);
|
/**
|
||||||
|
* Starts a runtime homed to the given world. All later mutation of the runtime happens on
|
||||||
|
* that world's thread. {@code adminStarted} runtimes skip the min-player pregame check.
|
||||||
|
*/
|
||||||
|
MinigameRuntime start(MinigameDefinition definition, MinigameMapCandidate map,
|
||||||
|
@Nullable String worldName, boolean adminStarted);
|
||||||
|
|
||||||
|
/** Ends a single runtime by its runtime id. */
|
||||||
|
void endRuntime(String runtimeId, String reason);
|
||||||
|
|
||||||
|
/** Ends every runtime of the given minigame id. */
|
||||||
|
void endAll(String minigameId, String reason);
|
||||||
|
|
||||||
|
/** Resets a single runtime by its runtime id. */
|
||||||
|
void resetRuntime(String runtimeId);
|
||||||
|
|
||||||
|
/** Resets every runtime of the given minigame id. */
|
||||||
|
void reset(String minigameId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central phase transition: updates the runtime, fires volume signals,
|
||||||
|
* and dispatches {@code on_phase_change}.
|
||||||
|
*/
|
||||||
|
void setPhase(MinigameRuntime runtime, MinigamePhase phase);
|
||||||
|
|
||||||
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
void onPlayerActivated(MinigameRuntime runtime, String playerId);
|
||||||
Optional<MinigameRuntime> runtime(String id);
|
|
||||||
Collection<MinigameRuntime> runtimes(String minigameId);
|
/**
|
||||||
|
* 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. */
|
||||||
|
void setupTeams(MinigameRuntime runtime);
|
||||||
|
|
||||||
|
/** Marks the player as LEFT, restores inventory, and ends the runtime when it empties. */
|
||||||
|
void removePlayer(MinigameRuntime runtime, String playerId, String reason);
|
||||||
|
|
||||||
|
/** Called from the runtime's home world thread when a previously LEFT player reconnects. */
|
||||||
|
void onPlayerReconnected(MinigameRuntime runtime, String playerId);
|
||||||
|
|
||||||
|
Optional<MinigameRuntime> runtimeById(String runtimeId);
|
||||||
|
|
||||||
|
Collection<MinigameRuntime> runtimesForMinigame(String minigameId);
|
||||||
|
|
||||||
Optional<MinigameRuntime> runtimeForArena(String minigameId, String mapId, String variantId);
|
Optional<MinigameRuntime> runtimeForArena(String minigameId, String mapId, String variantId);
|
||||||
|
|
||||||
Optional<MinigameRuntime> runtimeForPlayer(String playerId);
|
Optional<MinigameRuntime> runtimeForPlayer(String playerId);
|
||||||
|
|
||||||
Collection<MinigameRuntime> runtimes();
|
Collection<MinigameRuntime> runtimes();
|
||||||
|
|
||||||
void shutdownAll(String reason);
|
void shutdownAll(String reason);
|
||||||
|
|
||||||
void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload);
|
void dispatch(MinigameRuntime runtime, String eventId, Map<String, Object> payload);
|
||||||
void tickPregame(long nowMs);
|
|
||||||
|
/** Ticks pregame countdowns for runtimes homed in (or adopted by) the given world. */
|
||||||
|
void tickPregame(long nowMs, String worldName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package net.kewwbec.minigames.runtime;
|
||||||
|
|
||||||
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
import static net.kewwbec.minigames.model.Enums.WinCondition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for player ranking. Used by end-of-game scores, rewards,
|
||||||
|
* and the HUD scoreboard/position display.
|
||||||
|
*
|
||||||
|
* Players with status LEFT or SPECTATOR are never ranked. For LOWEST_TIME a score
|
||||||
|
* of 0 means "no recorded time" (DNF) and sorts last. Ties break by join time so
|
||||||
|
* the ordering is stable between refreshes.
|
||||||
|
*/
|
||||||
|
public final class Rankings {
|
||||||
|
private Rankings() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Map.Entry<String, PlayerMinigameState>> rank(MinigameRuntime runtime, boolean excludeEliminated) {
|
||||||
|
WinCondition wc = runtime.definition().getWinCondition();
|
||||||
|
Comparator<Map.Entry<String, PlayerMinigameState>> comparator = comparatorFor(wc)
|
||||||
|
.thenComparing(e -> e.getValue().joinedAt())
|
||||||
|
.thenComparing(Map.Entry::getKey);
|
||||||
|
return runtime.players().entrySet().stream()
|
||||||
|
.filter(e -> e.getValue().status() != PlayerStatus.LEFT)
|
||||||
|
.filter(e -> e.getValue().status() != PlayerStatus.SPECTATOR)
|
||||||
|
.filter(e -> !excludeEliminated || !runtime.eliminatedPlayers().contains(e.getKey()))
|
||||||
|
.sorted(comparator)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<String> rankedPlayerIds(MinigameRuntime runtime, boolean excludeEliminated) {
|
||||||
|
return rank(runtime, excludeEliminated).stream().map(Map.Entry::getKey).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Comparator<Map.Entry<String, PlayerMinigameState>> comparatorFor(WinCondition wc) {
|
||||||
|
if (wc == WinCondition.LOWEST_TIME) {
|
||||||
|
return Comparator.comparingLong(e -> {
|
||||||
|
int score = e.getValue().score();
|
||||||
|
return score <= 0 ? Long.MAX_VALUE : score;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Comparator<Map.Entry<String, PlayerMinigameState>> byScore =
|
||||||
|
Comparator.comparingInt(e -> e.getValue().score());
|
||||||
|
return wc == WinCondition.LOWEST_SCORE ? byScore : byScore.reversed();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,41 @@ package net.kewwbec.minigames.service;
|
|||||||
|
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.model.ValidationIssue;
|
import net.kewwbec.minigames.model.ValidationIssue;
|
||||||
import net.kewwbec.minigames.persistence.LocalJsonMinigameDefinitionStore;
|
import net.kewwbec.minigames.persistence.AssetPackMinigameDefinitionStore;
|
||||||
|
import net.kewwbec.minigames.persistence.MinigameDefinitionStore;
|
||||||
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 java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public final class DefaultMinigameService implements MinigameService {
|
public final class DefaultMinigameService implements MinigameService {
|
||||||
private static final String PACK_KEY = "net.kewwbec:core-minigames";
|
private static final Pattern VALID_ID = Pattern.compile("[A-Za-z0-9_-]+");
|
||||||
|
|
||||||
private final MinigameRuntimeService runtimeService;
|
private final MinigameRuntimeService runtimeService;
|
||||||
private final LocalJsonMinigameDefinitionStore store;
|
private final MinigameDefinitionStore store;
|
||||||
|
|
||||||
public DefaultMinigameService(Path dataRoot, MinigameRuntimeService runtimeService) {
|
public DefaultMinigameService(MinigameRuntimeService runtimeService) {
|
||||||
this.runtimeService = runtimeService;
|
this.runtimeService = runtimeService;
|
||||||
this.store = new LocalJsonMinigameDefinitionStore(dataRoot);
|
this.store = new AssetPackMinigameDefinitionStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValidId(String id) {
|
||||||
|
return id != null && VALID_ID.matcher(id).matches();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MinigameDefinition create(String id, String name) {
|
public MinigameDefinition create(String packName, String id, String name) {
|
||||||
|
if (!isValidId(id)) {
|
||||||
|
throw new IllegalArgumentException("minigames.error.invalid_id:" + id);
|
||||||
|
}
|
||||||
var definition = MinigameDefinition.create(id, name);
|
var definition = MinigameDefinition.create(id, name);
|
||||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition));
|
|
||||||
try {
|
try {
|
||||||
store.save(definition);
|
store.save(packName, definition);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new RuntimeException("Failed to save minigame definition: " + id, e);
|
throw new RuntimeException("Failed to save minigame definition: " + id, e);
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,6 @@ public final class DefaultMinigameService implements MinigameService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void update(MinigameDefinition definition) {
|
public void update(MinigameDefinition definition) {
|
||||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(definition));
|
|
||||||
try {
|
try {
|
||||||
store.save(definition);
|
store.save(definition);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -48,7 +54,7 @@ public final class DefaultMinigameService implements MinigameService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void delete(String id) {
|
public void delete(String id) {
|
||||||
runtimeService.end(id, "minigames.runtime.reason.definition_deleted");
|
runtimeService.endAll(id, "minigames.runtime.reason.definition_deleted");
|
||||||
try {
|
try {
|
||||||
store.delete(id);
|
store.delete(id);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -60,12 +66,7 @@ public final class DefaultMinigameService implements MinigameService {
|
|||||||
public void setEnabled(String id, boolean enabled) {
|
public void setEnabled(String id, boolean enabled) {
|
||||||
definition(id).ifPresent(def -> {
|
definition(id).ifPresent(def -> {
|
||||||
def.setEnabled(enabled);
|
def.setEnabled(enabled);
|
||||||
MinigameDefinition.getAssetStore().loadAssets(PACK_KEY, List.of(def));
|
update(def);
|
||||||
try {
|
|
||||||
store.save(def);
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("Failed to save minigame definition: " + id, e);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ public final class DefaultMinigameService implements MinigameService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void end(String id, String reason) {
|
public void end(String id, String reason) {
|
||||||
runtimeService.end(id, reason);
|
runtimeService.endAll(id, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -108,6 +109,8 @@ public final class DefaultMinigameService implements MinigameService {
|
|||||||
if (value.getMaxPlayers() < value.getMinPlayers()) errors.add(ValidationIssue.of("minigames.validation.max_players_below_min"));
|
if (value.getMaxPlayers() < value.getMinPlayers()) errors.add(ValidationIssue.of("minigames.validation.max_players_below_min"));
|
||||||
if (value.getRoundLengthSeconds() <= 0) errors.add(ValidationIssue.of("minigames.validation.round_length_not_positive"));
|
if (value.getRoundLengthSeconds() <= 0) errors.add(ValidationIssue.of("minigames.validation.round_length_not_positive"));
|
||||||
if (value.getCountdownSeconds() < 0) errors.add(ValidationIssue.of("minigames.validation.countdown_negative"));
|
if (value.getCountdownSeconds() < 0) errors.add(ValidationIssue.of("minigames.validation.countdown_negative"));
|
||||||
|
if (value.isOvertimeEnabled() && value.getOvertimeSeconds() <= 0) errors.add(ValidationIssue.of("minigames.validation.overtime_length_not_positive"));
|
||||||
|
if (value.isSuddenDeathEnabled() && value.getSuddenDeathSeconds() <= 0) errors.add(ValidationIssue.of("minigames.validation.sudden_death_length_not_positive"));
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
|||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.asset.TriggerEffectAsset;
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
||||||
import com.hypixel.hytale.component.Store;
|
import com.hypixel.hytale.component.Store;
|
||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.model.Enums;
|
||||||
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
import net.kewwbec.minigames.model.MinigameMapDiscoveryResult;
|
import net.kewwbec.minigames.model.MinigameMapDiscoveryResult;
|
||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
@@ -35,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) {
|
||||||
@@ -117,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;
|
||||||
@@ -126,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 "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,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()) {
|
||||||
@@ -164,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;
|
||||||
}
|
}
|
||||||
@@ -176,6 +252,54 @@ public final class MinigameMapDiscoveryService {
|
|||||||
return variantId.isBlank() || variantId.equals(runtime.variantId());
|
return variantId.isBlank() || variantId.equals(runtime.variantId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when some volume for this minigame (and map/variant — blank tags act as wildcards)
|
||||||
|
* carries a SetMinigamePhase effect that moves the game to ACTIVE. Games without one would
|
||||||
|
* be stuck in STARTING forever, so starting them is refused.
|
||||||
|
*/
|
||||||
|
public boolean hasActivationVolume(@Nullable Store<EntityStore> store, @Nonnull String minigameId,
|
||||||
|
@Nonnull String mapId, @Nonnull String variantId) {
|
||||||
|
TriggerVolumeManager manager = manager(store);
|
||||||
|
if (manager == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (VolumeEntry volume : manager.getVolumes()) {
|
||||||
|
Map<String, String> tags = volume.getRawTags();
|
||||||
|
if (!minigameId.equals(clean(tags.get(TAG_MINIGAME_ID)))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String volumeMap = clean(tags.get(TAG_MAP_ID));
|
||||||
|
if (!volumeMap.isBlank() && !mapId.isBlank() && !volumeMap.equals(mapId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String volumeVariant = clean(tags.get(TAG_VARIANT_ID));
|
||||||
|
if (!volumeVariant.isBlank() && !variantId.isBlank() && !volumeVariant.equals(variantId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (TriggerEffect effect : resolveEffects(volume)) {
|
||||||
|
if (effect instanceof net.kewwbec.minigames.volume.effect.SetMinigamePhaseEffect phaseEffect
|
||||||
|
&& phaseEffect.targetPhase() == Enums.MinigamePhase.ACTIVE
|
||||||
|
&& (phaseEffect.configuredMinigameId().isBlank() || phaseEffect.configuredMinigameId().equals(minigameId))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists a volume's effects, resolving an effect-asset reference when one is set. */
|
||||||
|
@Nonnull
|
||||||
|
public static Iterable<TriggerEffect> resolveEffects(@Nonnull VolumeEntry volume) {
|
||||||
|
String assetRef = volume.getEffectAssetRef();
|
||||||
|
if (assetRef != null && !assetRef.isBlank()) {
|
||||||
|
TriggerEffectAsset asset = AssetRegistry.getAssetStore(TriggerEffectAsset.class).getAssetMap().getAsset(assetRef);
|
||||||
|
if (asset != null) {
|
||||||
|
return List.of(asset.getEffects());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return volume.getEffects();
|
||||||
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private static TriggerVolumeManager manager(@Nullable Store<EntityStore> store) {
|
private static TriggerVolumeManager manager(@Nullable Store<EntityStore> store) {
|
||||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import net.kewwbec.minigames.runtime.MinigameRuntime;
|
|||||||
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
import net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@@ -15,15 +16,39 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import static net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
import static net.kewwbec.minigames.model.Enums.MapSelectionMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-game queue state per minigame id. All session mutation is synchronized on the
|
||||||
|
* session object because joins/votes arrive from multiple world threads.
|
||||||
|
*/
|
||||||
public final class MinigameQueueService {
|
public final class MinigameQueueService {
|
||||||
private final Map<String, Session> sessions = new HashMap<>();
|
/** How long the map-vote window stays open once the queue can start. */
|
||||||
|
public static final long VOTE_WINDOW_MS = 30_000L;
|
||||||
|
|
||||||
|
public enum JoinResult { JOINED, ALREADY_QUEUED, QUEUE_FULL, DISABLED }
|
||||||
|
|
||||||
|
private final Map<String, Session> sessions = new ConcurrentHashMap<>();
|
||||||
private final Random random = new Random();
|
private final Random random = new Random();
|
||||||
|
|
||||||
public void join(@Nonnull String minigameId, @Nonnull String playerId) {
|
@Nonnull
|
||||||
session(minigameId).players.add(playerId);
|
public JoinResult join(@Nonnull MinigameDefinition definition, @Nonnull String playerId) {
|
||||||
|
if (!definition.isEnabled()) {
|
||||||
|
return JoinResult.DISABLED;
|
||||||
|
}
|
||||||
|
Session session = sessions.computeIfAbsent(definition.getId(), ignored -> new Session());
|
||||||
|
synchronized (session) {
|
||||||
|
if (session.players.contains(playerId)) {
|
||||||
|
return JoinResult.ALREADY_QUEUED;
|
||||||
|
}
|
||||||
|
if (session.players.size() >= definition.getMaxPlayers()) {
|
||||||
|
return JoinResult.QUEUE_FULL;
|
||||||
|
}
|
||||||
|
session.players.add(playerId);
|
||||||
|
return JoinResult.JOINED;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void leave(@Nonnull String minigameId, @Nonnull String playerId) {
|
public void leave(@Nonnull String minigameId, @Nonnull String playerId) {
|
||||||
@@ -31,36 +56,125 @@ public final class MinigameQueueService {
|
|||||||
if (session == null) {
|
if (session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.players.remove(playerId);
|
synchronized (session) {
|
||||||
session.votes.remove(playerId);
|
session.players.remove(playerId);
|
||||||
if (session.players.isEmpty() && session.votes.isEmpty()) {
|
session.votes.remove(playerId);
|
||||||
sessions.remove(minigameId);
|
if (session.players.isEmpty()) {
|
||||||
|
session.voteDeadlineMs = 0L;
|
||||||
|
sessions.remove(minigameId, session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void vote(@Nonnull String minigameId, @Nonnull String playerId, @Nonnull String mapId) {
|
/** Removes the player from every queue (used on disconnect). */
|
||||||
Session session = session(minigameId);
|
public void leaveAll(@Nonnull String playerId) {
|
||||||
session.players.add(playerId);
|
for (String minigameId : sessions.keySet().toArray(String[]::new)) {
|
||||||
session.votes.put(playerId, mapId);
|
leave(minigameId, playerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Records a map vote. Only queued players may vote. Returns false if the player is not queued. */
|
||||||
|
public boolean vote(@Nonnull String minigameId, @Nonnull String playerId, @Nonnull String mapId) {
|
||||||
|
Session session = sessions.get(minigameId);
|
||||||
|
if (session == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
if (!session.players.contains(playerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session.votes.put(playerId, mapId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the map-vote window for a queue that has reached its start threshold.
|
||||||
|
* Returns true if the window was newly opened (callers show the vote UI then).
|
||||||
|
*/
|
||||||
|
public boolean openVoteWindow(@Nonnull String minigameId, long nowMs) {
|
||||||
|
Session session = sessions.get(minigameId);
|
||||||
|
if (session == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
if (session.voteDeadlineMs > 0L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
session.voteDeadlineMs = nowMs + VOTE_WINDOW_MS;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the vote window has been opened and has either expired or every queued player voted. */
|
||||||
|
public boolean voteWindowComplete(@Nonnull String minigameId, long nowMs) {
|
||||||
|
Session session = sessions.get(minigameId);
|
||||||
|
if (session == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
if (session.voteDeadlineMs <= 0L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return nowMs >= session.voteDeadlineMs || session.votes.keySet().containsAll(session.players);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minigame ids whose vote window is open (used by the pregame tick to start expired votes). */
|
||||||
|
@Nonnull
|
||||||
|
public List<String> openVoteWindows() {
|
||||||
|
var result = new ArrayList<String>();
|
||||||
|
sessions.forEach((id, session) -> {
|
||||||
|
synchronized (session) {
|
||||||
|
if (session.voteDeadlineMs > 0L) {
|
||||||
|
result.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves queued players (capped to MaxPlayers, in join order) into a new runtime.
|
||||||
|
* Players beyond the cap stay queued for the next game.
|
||||||
|
*/
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public MinigameRuntime startQueued(
|
public MinigameRuntime startQueued(
|
||||||
@Nonnull MinigameDefinition definition,
|
@Nonnull MinigameDefinition definition,
|
||||||
@Nonnull List<MinigameMapCandidate> candidates,
|
@Nonnull MinigameMapCandidate selected,
|
||||||
@Nonnull MinigameRuntimeService runtimeService
|
@Nonnull MinigameRuntimeService runtimeService,
|
||||||
|
@Nullable String worldName,
|
||||||
|
boolean adminStarted
|
||||||
) {
|
) {
|
||||||
MinigameMapCandidate selected = select(definition, candidates);
|
MinigameRuntime runtime = runtimeService.start(definition, selected, worldName, adminStarted);
|
||||||
MinigameRuntime runtime = runtimeService.start(definition, selected);
|
Session session = sessions.get(definition.getId());
|
||||||
Session session = session(definition.getId());
|
List<String> joining = new ArrayList<>();
|
||||||
for (String playerId : session.players) {
|
if (session != null) {
|
||||||
|
synchronized (session) {
|
||||||
|
for (String playerId : session.players) {
|
||||||
|
if (joining.size() >= definition.getMaxPlayers()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
joining.add(playerId);
|
||||||
|
}
|
||||||
|
joining.forEach(playerId -> {
|
||||||
|
session.players.remove(playerId);
|
||||||
|
session.votes.remove(playerId);
|
||||||
|
});
|
||||||
|
session.voteDeadlineMs = 0L;
|
||||||
|
if (session.players.isEmpty()) {
|
||||||
|
sessions.remove(definition.getId(), session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String playerId : joining) {
|
||||||
runtime.players().computeIfAbsent(playerId, ignored -> {
|
runtime.players().computeIfAbsent(playerId, ignored -> {
|
||||||
var player = new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId());
|
var player = new net.kewwbec.minigames.model.PlayerMinigameState(playerId, definition.getId());
|
||||||
player.lives(Math.max(0, definition.getDefaultPlayerLives()));
|
player.lives(Math.max(0, definition.getDefaultPlayerLives()));
|
||||||
return player;
|
return player;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sessions.remove(definition.getId());
|
runtimeService.setupTeams(runtime);
|
||||||
return runtime;
|
return runtime;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +190,8 @@ public final class MinigameQueueService {
|
|||||||
return available.get(random.nextInt(available.size()));
|
return available.get(random.nextInt(available.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Session session = session(minigameId);
|
Map<String, String> votes = votes(minigameId);
|
||||||
Optional<String> winningMapId = session.votes.values().stream()
|
Optional<String> winningMapId = votes.values().stream()
|
||||||
.collect(java.util.stream.Collectors.groupingBy(value -> value, java.util.stream.Collectors.counting()))
|
.collect(java.util.stream.Collectors.groupingBy(value -> value, java.util.stream.Collectors.counting()))
|
||||||
.entrySet()
|
.entrySet()
|
||||||
.stream()
|
.stream()
|
||||||
@@ -110,20 +224,29 @@ public final class MinigameQueueService {
|
|||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public Set<String> players(@Nonnull String minigameId) {
|
public Set<String> players(@Nonnull String minigameId) {
|
||||||
return Set.copyOf(session(minigameId).players);
|
Session session = sessions.get(minigameId);
|
||||||
|
if (session == null) {
|
||||||
|
return Set.of();
|
||||||
|
}
|
||||||
|
synchronized (session) {
|
||||||
|
return new LinkedHashSet<>(session.players);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public Map<String, String> votes(@Nonnull String minigameId) {
|
public Map<String, String> votes(@Nonnull String minigameId) {
|
||||||
return Map.copyOf(session(minigameId).votes);
|
Session session = sessions.get(minigameId);
|
||||||
}
|
if (session == null) {
|
||||||
|
return Map.of();
|
||||||
private Session session(@Nonnull String minigameId) {
|
}
|
||||||
return sessions.computeIfAbsent(minigameId, ignored -> new Session());
|
synchronized (session) {
|
||||||
|
return Map.copyOf(session.votes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class Session {
|
private static final class Session {
|
||||||
private final Set<String> players = new LinkedHashSet<>();
|
private final Set<String> players = new LinkedHashSet<>();
|
||||||
private final Map<String, String> votes = new HashMap<>();
|
private final Map<String, String> votes = new HashMap<>();
|
||||||
|
private long voteDeadlineMs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface MinigameService {
|
public interface MinigameService {
|
||||||
MinigameDefinition create(String id, String name);
|
MinigameDefinition create(String packName, String id, String name);
|
||||||
void update(MinigameDefinition definition);
|
void update(MinigameDefinition definition);
|
||||||
void delete(String id);
|
void delete(String id);
|
||||||
void setEnabled(String id, boolean enabled);
|
void setEnabled(String id, boolean enabled);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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;
|
||||||
|
|
||||||
public record MinigameServices(
|
public record MinigameServices(
|
||||||
MinigameService minigames,
|
MinigameService minigames,
|
||||||
@@ -9,6 +11,8 @@ public record MinigameServices(
|
|||||||
HytaleServerAdapters adapters,
|
HytaleServerAdapters adapters,
|
||||||
MinigameMapDiscoveryService maps,
|
MinigameMapDiscoveryService maps,
|
||||||
MinigameQueueService queue,
|
MinigameQueueService queue,
|
||||||
MinigameVolumeSignalService signals
|
MinigameVolumeSignalService signals,
|
||||||
|
MinigameMenuService menus,
|
||||||
|
MinigameStatsService stats
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package net.kewwbec.minigames.service;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.component.Store;
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
import com.hypixel.hytale.server.core.Message;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
|
import net.kewwbec.minigames.model.MinigameMapCandidate;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single entry point for "start a game from the queue" so the command, the trigger
|
||||||
|
* effect, the queue UI, and the vote-window tick all apply the same checks:
|
||||||
|
* WorldId restriction, ACTIVE-phase activation volume, then map selection.
|
||||||
|
*/
|
||||||
|
public final class MinigameStarter {
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private MinigameStarter() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a map and starts the queued game. Returns null (and notifies queued players)
|
||||||
|
* when the start is refused. {@code store} may be null for console-initiated starts,
|
||||||
|
* which skips the world-bound checks.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static MinigameRuntime startQueued(MinigameServices services, MinigameDefinition def,
|
||||||
|
@Nullable Store<EntityStore> store, boolean adminStarted) {
|
||||||
|
String worldName = store != null ? store.getExternalData().getWorld().getName() : null;
|
||||||
|
|
||||||
|
String requiredWorld = def.getWorldId();
|
||||||
|
if (!requiredWorld.isBlank() && worldName != null && !requiredWorld.equals(worldName)) {
|
||||||
|
LOGGER.atWarning().log("Refusing to start minigame=%s in world=%s: definition is restricted to world=%s",
|
||||||
|
def.getId(), worldName, requiredWorld);
|
||||||
|
notifyQueued(services, def, Message.translation("minigames.start.error.wrong_world")
|
||||||
|
.param("id", def.getId()).param("world", requiredWorld));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var discovered = services.maps().discover(store, def.getId());
|
||||||
|
MinigameMapCandidate selected = services.queue().select(def, discovered.candidates());
|
||||||
|
|
||||||
|
if (store != null && !services.maps().hasActivationVolume(store, def.getId(), selected.mapId(), selected.variantId())) {
|
||||||
|
LOGGER.atWarning().log("Refusing to start minigame=%s map=%s variant=%s: no volume sets phase ACTIVE for it. "
|
||||||
|
+ "Add a trigger volume with a SetMinigamePhase(ACTIVE) effect tagged with the minigame/map/variant ids.",
|
||||||
|
def.getId(), selected.mapId(), selected.variantId());
|
||||||
|
notifyQueued(services, def, Message.translation("minigames.start.error.no_activation_volume")
|
||||||
|
.param("id", def.getId()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return services.queue().startQueued(def, selected, services.runtime(), worldName, adminStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void notifyQueued(MinigameServices services, MinigameDefinition def, Message message) {
|
||||||
|
for (String playerId : services.queue().players(def.getId())) {
|
||||||
|
services.adapters().sendMessage(playerId, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import com.hypixel.hytale.server.core.universe.world.World;
|
|||||||
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
||||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||||
import net.kewwbec.minigames.model.Enums.MinigamePhase;
|
import net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||||
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
@@ -23,7 +24,7 @@ import java.util.concurrent.Executors;
|
|||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +37,8 @@ import java.util.logging.Level;
|
|||||||
* SignalPhaseTick = <phase> fire repeatedly while the given phase is active
|
* SignalPhaseTick = <phase> fire repeatedly while the given phase is active
|
||||||
* SignalTickDelay = <seconds> repeat interval (0 = 50ms minimum)
|
* SignalTickDelay = <seconds> repeat interval (0 = 50ms minimum)
|
||||||
* SignalOnTimerExpire = <timerName> fire once when the named timer expires
|
* SignalOnTimerExpire = <timerName> fire once when the named timer expires
|
||||||
|
*
|
||||||
|
* Verbose diagnostics are only emitted when the minigame definition has Debug=true.
|
||||||
*/
|
*/
|
||||||
public final class MinigameVolumeSignalService {
|
public final class MinigameVolumeSignalService {
|
||||||
|
|
||||||
@@ -65,146 +68,107 @@ public final class MinigameVolumeSignalService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when a round advances. Fires one-shot RoundStart signals and starts RoundTick loops.
|
* Called when a round advances. Fires one-shot RoundStart signals and starts RoundTick loops.
|
||||||
* Must be called from the world thread.
|
* Must be called from the runtime's home world thread.
|
||||||
*/
|
*/
|
||||||
public void onRoundStart(MinigameRuntime runtime, int newRound) {
|
public void onRoundStart(MinigameRuntime runtime, int newRound) {
|
||||||
cancelLoops(roundLoops, runtime.runtimeId());
|
cancelLoops(roundLoops, runtime.runtimeId());
|
||||||
|
boolean debug = isDebug(runtime.minigameId());
|
||||||
|
|
||||||
String roundKey = String.valueOf(newRound);
|
String roundKey = String.valueOf(newRound);
|
||||||
LOGGER.atInfo().log("Minigame signal: round start runtime=%s minigame=%s map=%s variant=%s round=%s",
|
if (debug) {
|
||||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), roundKey);
|
LOGGER.atInfo().log("Minigame signal: round start runtime=%s minigame=%s round=%s",
|
||||||
|
runtime.runtimeId(), runtime.minigameId(), roundKey);
|
||||||
|
}
|
||||||
|
|
||||||
int worldsChecked = 0;
|
for (World world : worlds(runtime)) {
|
||||||
int volumesChecked = 0;
|
|
||||||
int startMatches = 0;
|
|
||||||
int tickMatches = 0;
|
|
||||||
|
|
||||||
for (World world : worlds()) {
|
|
||||||
worldsChecked++;
|
|
||||||
TriggerVolumeManager manager = manager(world);
|
TriggerVolumeManager manager = manager(world);
|
||||||
if (manager == null) {
|
if (manager == null) continue;
|
||||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ActorPair actor = findActor(runtime, world);
|
ActorPair actor = findActor(runtime, world);
|
||||||
if (actor == null) {
|
if (actor == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
if (debug) {
|
||||||
world.getName(), runtime.players().keySet());
|
LOGGER.atInfo().log("Minigame signal: round start skipped world=%s reason=no runtime player actor", world.getName());
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (VolumeEntry volume : manager.getVolumes()) {
|
for (VolumeEntry volume : manager.getVolumes()) {
|
||||||
volumesChecked++;
|
|
||||||
Map<String, String> tags = volume.getRawTags();
|
Map<String, String> tags = volume.getRawTags();
|
||||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||||
if (hasRoundSignal(tags)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: round volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), runtime.minigameId(), tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot: fire immediately
|
// One-shot: fire immediately
|
||||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_START))) {
|
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_START))) {
|
||||||
startMatches++;
|
if (debug) {
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart match volume=%s world=%s round=%s tags=%s",
|
LOGGER.atInfo().log("Minigame signal: SignalRoundStart match volume=%s world=%s round=%s",
|
||||||
volume.getId(), world.getName(), roundKey, tags);
|
volume.getId(), world.getName(), roundKey);
|
||||||
|
}
|
||||||
toggle(manager, volume.getId(), actor);
|
toggle(manager, volume.getId(), actor);
|
||||||
} else if (tags.containsKey(TAG_SIGNAL_ROUND_START)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalRoundStart no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), roundKey, tags.get(TAG_SIGNAL_ROUND_START), tags);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repeating: schedule loop
|
// Repeating: schedule loop
|
||||||
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_TICK))) {
|
if (roundKey.equals(tags.get(TAG_SIGNAL_ROUND_TICK))) {
|
||||||
tickMatches++;
|
|
||||||
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalRoundTick schedule volume=%s world=%s round=%s delayMs=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), roundKey, delay, tags);
|
|
||||||
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
||||||
TAG_SIGNAL_ROUND_TICK, roundKey, volume.getId(), world.getName(), delay);
|
TAG_SIGNAL_ROUND_TICK, roundKey, volume.getId(), world.getName(), delay);
|
||||||
roundLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
roundLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
||||||
} else if (tags.containsKey(TAG_SIGNAL_ROUND_TICK)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalRoundTick no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), roundKey, tags.get(TAG_SIGNAL_ROUND_TICK), tags);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.atInfo().log("Minigame signal: round start scan complete runtime=%s worlds=%s volumes=%s startMatches=%s tickMatches=%s",
|
|
||||||
runtime.runtimeId(), worldsChecked, volumesChecked, startMatches, tickMatches);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the game phase changes. Cancels old phase loops and starts new ones.
|
* Called when the game phase changes. Cancels old phase loops, fires one-shot PhaseStart
|
||||||
* Must be called from the world thread.
|
* signals, and (when {@code allowLoops}) starts PhaseTick loops. Pass {@code allowLoops=false}
|
||||||
|
* for terminal transitions where the runtime is about to be removed — loops registered there
|
||||||
|
* could never be cancelled.
|
||||||
|
* Must be called from the runtime's home world thread.
|
||||||
*/
|
*/
|
||||||
public void onPhaseChange(MinigameRuntime runtime, MinigamePhase newPhase) {
|
public void onPhaseChange(MinigameRuntime runtime, MinigamePhase newPhase, boolean allowLoops) {
|
||||||
cancelLoops(phaseLoops, runtime.runtimeId());
|
cancelLoops(phaseLoops, runtime.runtimeId());
|
||||||
|
boolean debug = isDebug(runtime.minigameId());
|
||||||
|
|
||||||
String phaseKey = newPhase.name();
|
String phaseKey = newPhase.name();
|
||||||
LOGGER.atInfo().log("Minigame signal: phase change runtime=%s minigame=%s map=%s variant=%s phase=%s",
|
if (debug) {
|
||||||
runtime.runtimeId(), runtime.minigameId(), runtime.mapId(), runtime.variantId(), phaseKey);
|
LOGGER.atInfo().log("Minigame signal: phase change runtime=%s minigame=%s phase=%s allowLoops=%s",
|
||||||
|
runtime.runtimeId(), runtime.minigameId(), phaseKey, allowLoops);
|
||||||
|
}
|
||||||
|
|
||||||
int worldsChecked = 0;
|
for (World world : worlds(runtime)) {
|
||||||
int volumesChecked = 0;
|
|
||||||
int startMatches = 0;
|
|
||||||
int tickMatches = 0;
|
|
||||||
|
|
||||||
for (World world : worlds()) {
|
|
||||||
worldsChecked++;
|
|
||||||
TriggerVolumeManager manager = manager(world);
|
TriggerVolumeManager manager = manager(world);
|
||||||
if (manager == null) {
|
if (manager == null) continue;
|
||||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no TriggerVolumeManager", world.getName());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ActorPair actor = findActor(runtime, world);
|
ActorPair actor = findActor(runtime, world);
|
||||||
if (actor == null) {
|
if (actor == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
if (debug) {
|
||||||
world.getName(), runtime.players().keySet());
|
LOGGER.atInfo().log("Minigame signal: phase change skipped world=%s reason=no runtime player actor", world.getName());
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (VolumeEntry volume : manager.getVolumes()) {
|
for (VolumeEntry volume : manager.getVolumes()) {
|
||||||
volumesChecked++;
|
|
||||||
Map<String, String> tags = volume.getRawTags();
|
Map<String, String> tags = volume.getRawTags();
|
||||||
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
if (!runtime.minigameId().equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||||
if (hasPhaseSignal(tags)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: phase volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), runtime.minigameId(), tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_START))) {
|
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_START))) {
|
||||||
startMatches++;
|
if (debug) {
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart match volume=%s world=%s phase=%s tags=%s",
|
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart match volume=%s world=%s phase=%s",
|
||||||
volume.getId(), world.getName(), phaseKey, tags);
|
volume.getId(), world.getName(), phaseKey);
|
||||||
|
}
|
||||||
toggle(manager, volume.getId(), actor);
|
toggle(manager, volume.getId(), actor);
|
||||||
} else if (tags.containsKey(TAG_SIGNAL_PHASE_START)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseStart no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), phaseKey, tags.get(TAG_SIGNAL_PHASE_START), tags);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_TICK))) {
|
if (allowLoops && phaseKey.equals(tags.get(TAG_SIGNAL_PHASE_TICK))) {
|
||||||
tickMatches++;
|
|
||||||
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
long delay = parseDelayMs(tags.get(TAG_SIGNAL_TICK_DELAY));
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseTick schedule volume=%s world=%s phase=%s delayMs=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), phaseKey, delay, tags);
|
|
||||||
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
Future<?> future = scheduleLoop(runtime.runtimeId(), runtime.minigameId(),
|
||||||
TAG_SIGNAL_PHASE_TICK, phaseKey, volume.getId(), world.getName(), delay);
|
TAG_SIGNAL_PHASE_TICK, phaseKey, volume.getId(), world.getName(), delay);
|
||||||
phaseLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
phaseLoops.computeIfAbsent(runtime.runtimeId(), k -> new ArrayList<>()).add(future);
|
||||||
} else if (tags.containsKey(TAG_SIGNAL_PHASE_TICK)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalPhaseTick no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), phaseKey, tags.get(TAG_SIGNAL_PHASE_TICK), tags);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.atInfo().log("Minigame signal: phase change scan complete runtime=%s worlds=%s volumes=%s startMatches=%s tickMatches=%s",
|
|
||||||
runtime.runtimeId(), worldsChecked, volumesChecked, startMatches, tickMatches);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,8 +179,10 @@ public final class MinigameVolumeSignalService {
|
|||||||
public void startTimer(MinigameRuntime runtime, String timerName, long durationMs) {
|
public void startTimer(MinigameRuntime runtime, String timerName, long durationMs) {
|
||||||
cancelTimer(runtime.runtimeId(), timerName);
|
cancelTimer(runtime.runtimeId(), timerName);
|
||||||
runtime.timers().put(timerName, System.currentTimeMillis() + durationMs);
|
runtime.timers().put(timerName, System.currentTimeMillis() + durationMs);
|
||||||
LOGGER.atInfo().log("Minigame signal: start timer runtime=%s minigame=%s timer=%s durationMs=%s",
|
if (isDebug(runtime.minigameId())) {
|
||||||
runtime.runtimeId(), runtime.minigameId(), timerName, durationMs);
|
LOGGER.atInfo().log("Minigame signal: start timer runtime=%s minigame=%s timer=%s durationMs=%s",
|
||||||
|
runtime.runtimeId(), runtime.minigameId(), timerName, durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
Future<?> future = scheduler.schedule(
|
Future<?> future = scheduler.schedule(
|
||||||
() -> fireTimerExpiry(runtime.runtimeId(), runtime.minigameId(), timerName),
|
() -> fireTimerExpiry(runtime.runtimeId(), runtime.minigameId(), timerName),
|
||||||
@@ -234,7 +200,6 @@ public final class MinigameVolumeSignalService {
|
|||||||
Future<?> f = futures.remove(timerName);
|
Future<?> f = futures.remove(timerName);
|
||||||
if (f != null) {
|
if (f != null) {
|
||||||
f.cancel(false);
|
f.cancel(false);
|
||||||
LOGGER.atInfo().log("Minigame signal: cancelled timer runtime=%s timer=%s", runtimeId, timerName);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +210,6 @@ public final class MinigameVolumeSignalService {
|
|||||||
cancelLoops(phaseLoops, runtimeId);
|
cancelLoops(phaseLoops, runtimeId);
|
||||||
var futures = timerFutures.remove(runtimeId);
|
var futures = timerFutures.remove(runtimeId);
|
||||||
if (futures != null) futures.values().forEach(f -> f.cancel(false));
|
if (futures != null) futures.values().forEach(f -> f.cancel(false));
|
||||||
LOGGER.atInfo().log("Minigame signal: cancelled all loops and timers runtime=%s", runtimeId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shuts down the background scheduler. Call on plugin shutdown. */
|
/** Shuts down the background scheduler. Call on plugin shutdown. */
|
||||||
@@ -255,85 +219,80 @@ public final class MinigameVolumeSignalService {
|
|||||||
|
|
||||||
private Future<?> scheduleLoop(String runtimeId, String minigameId, String tagKey, String tagValue,
|
private Future<?> scheduleLoop(String runtimeId, String minigameId, String tagKey, String tagValue,
|
||||||
String volumeId, String worldName, long delayMs) {
|
String volumeId, String worldName, long delayMs) {
|
||||||
LOGGER.atInfo().log("Minigame signal: schedule loop runtime=%s minigame=%s world=%s volume=%s tag=%s value=%s delayMs=%s",
|
// The task keeps a handle to its own future so it can cancel itself once the
|
||||||
runtimeId, minigameId, worldName, volumeId, tagKey, tagValue, delayMs);
|
// runtime is gone or the tag no longer matches — otherwise a fixed-rate loop
|
||||||
return scheduler.scheduleAtFixedRate(
|
// whose owner disappeared would spin forever.
|
||||||
() -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName),
|
AtomicReference<Future<?>> self = new AtomicReference<>();
|
||||||
|
Future<?> future = scheduler.scheduleAtFixedRate(
|
||||||
|
() -> tickSignal(runtimeId, minigameId, tagKey, tagValue, volumeId, worldName, self),
|
||||||
delayMs, delayMs, TimeUnit.MILLISECONDS
|
delayMs, delayMs, TimeUnit.MILLISECONDS
|
||||||
);
|
);
|
||||||
|
self.set(future);
|
||||||
|
return future;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void tickSignal(String runtimeId, String minigameId, String expectedTagKey, String expectedTagValue,
|
private void tickSignal(String runtimeId, String minigameId, String expectedTagKey, String expectedTagValue,
|
||||||
String volumeId, String worldName) {
|
String volumeId, String worldName, AtomicReference<Future<?>> self) {
|
||||||
try {
|
try {
|
||||||
Universe universe = Universe.get();
|
Universe universe = Universe.get();
|
||||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||||
if (universe == null || plugin == null) {
|
if (universe == null || plugin == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s reason=universe or TriggerVolumesPlugin unavailable",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
World world = universe.getWorld(worldName);
|
World world = universe.getWorld(worldName);
|
||||||
if (world == null) {
|
if (world == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=world not found",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, worldName);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
world.execute(() -> {
|
world.execute(() -> {
|
||||||
var services = MinigameCorePlugin.getServices();
|
var services = MinigameCorePlugin.getServices();
|
||||||
if (services == null) {
|
if (services == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=services unavailable",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, worldName);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rtOpt = services.runtime().runtime(runtimeId);
|
var rtOpt = services.runtime().runtimeById(runtimeId);
|
||||||
if (rtOpt.isEmpty()) {
|
if (rtOpt.isEmpty()) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=runtime not found",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, worldName);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
MinigameRuntime rt = rtOpt.get();
|
MinigameRuntime rt = rtOpt.get();
|
||||||
|
|
||||||
if (TAG_SIGNAL_ROUND_TICK.equals(expectedTagKey)) {
|
if (TAG_SIGNAL_ROUND_TICK.equals(expectedTagKey)) {
|
||||||
if (!expectedTagValue.equals(String.valueOf(rt.roundNumber()))) {
|
if (!expectedTagValue.equals(String.valueOf(rt.roundNumber()))) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick stopped runtime=%s volume=%s reason=round changed expected=%s actual=%s",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, expectedTagValue, rt.roundNumber());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (TAG_SIGNAL_PHASE_TICK.equals(expectedTagKey)) {
|
} else if (TAG_SIGNAL_PHASE_TICK.equals(expectedTagKey)) {
|
||||||
if (!expectedTagValue.equals(rt.phase().name())) {
|
if (!expectedTagValue.equals(rt.phase().name())) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick stopped runtime=%s volume=%s reason=phase changed expected=%s actual=%s",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, expectedTagValue, rt.phase().name());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||||
if (manager == null) {
|
if (manager == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=no TriggerVolumeManager",
|
|
||||||
runtimeId, volumeId, worldName);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
VolumeEntry volume = manager.getVolume(volumeId);
|
VolumeEntry volume = manager.getVolume(volumeId);
|
||||||
if (volume == null) {
|
if (volume == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=volume not found",
|
cancelSelf(self);
|
||||||
runtimeId, volumeId, worldName);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ActorPair actor = findActor(rt, world);
|
ActorPair actor = findActor(rt, world);
|
||||||
if (actor == null) {
|
if (actor == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: tick skipped runtime=%s volume=%s world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
|
||||||
runtimeId, volumeId, worldName, rt.players().keySet());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOGGER.atInfo().log("Minigame signal: tick firing runtime=%s minigame=%s world=%s volume=%s tag=%s value=%s tags=%s",
|
if (isDebug(minigameId)) {
|
||||||
runtimeId, minigameId, worldName, volumeId, expectedTagKey, expectedTagValue, volume.getRawTags());
|
LOGGER.atInfo().log("Minigame signal: tick firing runtime=%s world=%s volume=%s tag=%s value=%s",
|
||||||
|
runtimeId, worldName, volumeId, expectedTagKey, expectedTagValue);
|
||||||
|
}
|
||||||
toggle(manager, volumeId, actor);
|
toggle(manager, volumeId, actor);
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -341,89 +300,90 @@ public final class MinigameVolumeSignalService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void cancelSelf(AtomicReference<Future<?>> self) {
|
||||||
|
Future<?> f = self.get();
|
||||||
|
if (f != null) {
|
||||||
|
f.cancel(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void fireTimerExpiry(String runtimeId, String minigameId, String timerName) {
|
private void fireTimerExpiry(String runtimeId, String minigameId, String timerName) {
|
||||||
try {
|
try {
|
||||||
Universe universe = Universe.get();
|
Universe universe = Universe.get();
|
||||||
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
TriggerVolumesPlugin plugin = TriggerVolumesPlugin.get();
|
||||||
if (universe == null || plugin == null) {
|
if (universe == null || plugin == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s reason=universe or TriggerVolumesPlugin unavailable",
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var services = MinigameCorePlugin.getServices();
|
||||||
|
if (services == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var rtOpt = services.runtime().runtimeById(runtimeId);
|
||||||
|
if (rtOpt.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route to the runtime's home world so all runtime mutation stays on one thread.
|
||||||
|
World world = homeWorld(rtOpt.get(), universe);
|
||||||
|
if (world == null) {
|
||||||
|
LOGGER.atWarning().log("Minigame signal: timer expiry dropped runtime=%s timer=%s reason=home world unavailable",
|
||||||
runtimeId, timerName);
|
runtimeId, timerName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var dispatched = new AtomicBoolean(false);
|
world.execute(() -> {
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expired runtime=%s minigame=%s timer=%s",
|
var svc = MinigameCorePlugin.getServices();
|
||||||
runtimeId, minigameId, timerName);
|
if (svc == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var opt = svc.runtime().runtimeById(runtimeId);
|
||||||
|
if (opt.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameRuntime rt = opt.get();
|
||||||
|
boolean debug = isDebug(minigameId);
|
||||||
|
if (debug) {
|
||||||
|
LOGGER.atInfo().log("Minigame signal: timer expired runtime=%s minigame=%s timer=%s",
|
||||||
|
runtimeId, minigameId, timerName);
|
||||||
|
}
|
||||||
|
|
||||||
for (World world : worlds()) {
|
svc.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName));
|
||||||
world.execute(() -> {
|
|
||||||
var services = MinigameCorePlugin.getServices();
|
|
||||||
if (services == null) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=services unavailable",
|
|
||||||
runtimeId, timerName, world.getName());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rtOpt = services.runtime().runtime(runtimeId);
|
// Internal timers (name starts with '$') are handled by the runtime service,
|
||||||
if (rtOpt.isEmpty()) {
|
// not by volume signals.
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=runtime not found",
|
if (timerName.startsWith("$")) {
|
||||||
runtimeId, timerName, world.getName());
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
MinigameRuntime rt = rtOpt.get();
|
|
||||||
|
|
||||||
if (dispatched.compareAndSet(false, true)) {
|
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||||
LOGGER.atInfo().log("Minigame signal: dispatch on_timer_expired runtime=%s timer=%s", runtimeId, timerName);
|
if (manager == null) {
|
||||||
services.runtime().dispatch(rt, "on_timer_expired", Map.of("timer_name", timerName));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal timers (name starts with '$') are handled by the runtime service,
|
ActorPair actor = findActor(rt, world);
|
||||||
// not by volume signals.
|
if (actor == null) {
|
||||||
if (timerName.startsWith("$")) {
|
if (debug) {
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry not toggling volumes runtime=%s timer=%s reason=internal timer",
|
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s reason=no runtime player actor",
|
||||||
runtimeId, timerName);
|
runtimeId, timerName);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
TriggerVolumeManager manager = world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
for (VolumeEntry volume : manager.getVolumes()) {
|
||||||
if (manager == null) {
|
Map<String, String> tags = volume.getRawTags();
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no TriggerVolumeManager",
|
if (!minigameId.equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
||||||
runtimeId, timerName, world.getName());
|
continue;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (timerName.equals(tags.get(TAG_SIGNAL_TIMER_EXPIRE))) {
|
||||||
ActorPair actor = findActor(rt, world);
|
if (debug) {
|
||||||
if (actor == null) {
|
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire match volume=%s world=%s timer=%s",
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry skipped runtime=%s timer=%s world=%s reason=no runtime player actor in this world runtimePlayers=%s",
|
volume.getId(), world.getName(), timerName);
|
||||||
runtimeId, timerName, world.getName(), rt.players().keySet());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int matches = 0;
|
|
||||||
|
|
||||||
for (VolumeEntry volume : manager.getVolumes()) {
|
|
||||||
Map<String, String> tags = volume.getRawTags();
|
|
||||||
if (!minigameId.equals(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID))) {
|
|
||||||
if (tags.containsKey(TAG_SIGNAL_TIMER_EXPIRE)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: timer volume skipped volume=%s world=%s reason=minigame mismatch expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), minigameId, tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID), tags);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (timerName.equals(tags.get(TAG_SIGNAL_TIMER_EXPIRE))) {
|
|
||||||
matches++;
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire match volume=%s world=%s timer=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), timerName, tags);
|
|
||||||
toggle(manager, volume.getId(), actor);
|
|
||||||
} else if (tags.containsKey(TAG_SIGNAL_TIMER_EXPIRE)) {
|
|
||||||
LOGGER.atInfo().log("Minigame signal: SignalOnTimerExpire no match volume=%s world=%s expected=%s actual=%s tags=%s",
|
|
||||||
volume.getId(), world.getName(), timerName, tags.get(TAG_SIGNAL_TIMER_EXPIRE), tags);
|
|
||||||
}
|
}
|
||||||
|
toggle(manager, volume.getId(), actor);
|
||||||
}
|
}
|
||||||
LOGGER.atInfo().log("Minigame signal: timer expiry scan complete runtime=%s timer=%s world=%s matches=%s",
|
}
|
||||||
runtimeId, timerName, world.getName(), matches);
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.at(Level.WARNING).withCause(e).log("Error in minigame timer expiry for runtime=%s timer=%s", runtimeId, timerName);
|
LOGGER.at(Level.WARNING).withCause(e).log("Error in minigame timer expiry for runtime=%s timer=%s", runtimeId, timerName);
|
||||||
}
|
}
|
||||||
@@ -433,13 +393,10 @@ public final class MinigameVolumeSignalService {
|
|||||||
private static void toggle(TriggerVolumeManager manager, String volumeId, ActorPair actor) {
|
private static void toggle(TriggerVolumeManager manager, String volumeId, ActorPair actor) {
|
||||||
VolumeEntry volume = manager.getVolume(volumeId);
|
VolumeEntry volume = manager.getVolume(volumeId);
|
||||||
if (volume == null) {
|
if (volume == null) {
|
||||||
LOGGER.atInfo().log("Minigame signal: toggle skipped volume=%s reason=volume not found", volumeId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String current = volume.getRawTags().get(TAG_SIGNAL_KEY);
|
String current = volume.getRawTags().get(TAG_SIGNAL_KEY);
|
||||||
String next = "1".equals(current) ? "0" : "1";
|
String next = "1".equals(current) ? "0" : "1";
|
||||||
LOGGER.atInfo().log("Minigame signal: toggle volume=%s %s=%s->%s actor=%s tags=%s",
|
|
||||||
volumeId, TAG_SIGNAL_KEY, current, next, actor.uuid(), volume.getRawTags());
|
|
||||||
manager.setTag(volumeId, TAG_SIGNAL_KEY, next, actor.ref(), actor.uuid());
|
manager.setTag(volumeId, TAG_SIGNAL_KEY, next, actor.ref(), actor.uuid());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,16 +425,44 @@ public final class MinigameVolumeSignalService {
|
|||||||
return world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
return world.getEntityStore().getStore().getResource(plugin.getManagerResourceType());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Iterable<World> worlds() {
|
/** The runtime's home world when known, otherwise all worlds (legacy fallback). */
|
||||||
|
private static Iterable<World> worlds(MinigameRuntime runtime) {
|
||||||
Universe universe = Universe.get();
|
Universe universe = Universe.get();
|
||||||
return universe != null ? universe.getWorlds().values() : List.of();
|
if (universe == null) return List.of();
|
||||||
|
String home = runtime.worldName();
|
||||||
|
if (!home.isBlank()) {
|
||||||
|
World world = universe.getWorld(home);
|
||||||
|
return world != null ? List.of(world) : List.of();
|
||||||
|
}
|
||||||
|
return universe.getWorlds().values();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static World homeWorld(MinigameRuntime runtime, Universe universe) {
|
||||||
|
String home = runtime.worldName();
|
||||||
|
if (!home.isBlank()) {
|
||||||
|
return universe.getWorld(home);
|
||||||
|
}
|
||||||
|
// No home world recorded (runtime started without world context): fall back to the
|
||||||
|
// first world that currently hosts one of its players.
|
||||||
|
for (String playerId : runtime.players().keySet()) {
|
||||||
|
try {
|
||||||
|
PlayerRef playerRef = universe.getPlayer(UUID.fromString(playerId));
|
||||||
|
if (playerRef == null) continue;
|
||||||
|
Ref<EntityStore> ref = playerRef.getReference();
|
||||||
|
if (ref == null || !ref.isValid()) continue;
|
||||||
|
World world = ref.getStore().getExternalData().getWorld();
|
||||||
|
if (world != null) return world;
|
||||||
|
} catch (IllegalArgumentException ignored) {}
|
||||||
|
}
|
||||||
|
var worlds = universe.getWorlds().values().iterator();
|
||||||
|
return worlds.hasNext() ? worlds.next() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void cancelLoops(Map<String, List<Future<?>>> loopMap, String runtimeId) {
|
private static void cancelLoops(Map<String, List<Future<?>>> loopMap, String runtimeId) {
|
||||||
List<Future<?>> futures = loopMap.remove(runtimeId);
|
List<Future<?>> futures = loopMap.remove(runtimeId);
|
||||||
if (futures != null) {
|
if (futures != null) {
|
||||||
futures.forEach(f -> f.cancel(false));
|
futures.forEach(f -> f.cancel(false));
|
||||||
LOGGER.atInfo().log("Minigame signal: cancelled loops runtime=%s count=%s", runtimeId, futures.size());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,17 +472,13 @@ public final class MinigameVolumeSignalService {
|
|||||||
double seconds = Double.parseDouble(value);
|
double seconds = Double.parseDouble(value);
|
||||||
return Math.max(MIN_DELAY_MS, (long) (seconds * 1000));
|
return Math.max(MIN_DELAY_MS, (long) (seconds * 1000));
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
LOGGER.atInfo().log("Minigame signal: invalid SignalTickDelay value=%s usingDefaultMs=%s", value, MIN_DELAY_MS);
|
|
||||||
return MIN_DELAY_MS;
|
return MIN_DELAY_MS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasRoundSignal(Map<String, String> tags) {
|
private static boolean isDebug(String minigameId) {
|
||||||
return tags.containsKey(TAG_SIGNAL_ROUND_START) || tags.containsKey(TAG_SIGNAL_ROUND_TICK);
|
MinigameDefinition def = MinigameDefinition.getAssetMap().getAsset(minigameId);
|
||||||
}
|
return def != null && def.isDebug();
|
||||||
|
|
||||||
private static boolean hasPhaseSignal(Map<String, String> tags) {
|
|
||||||
return tags.containsKey(TAG_SIGNAL_PHASE_START) || tags.containsKey(TAG_SIGNAL_PHASE_TICK);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ActorPair(Ref<EntityStore> ref, UUID uuid) {}
|
private record ActorPair(Ref<EntityStore> ref, UUID uuid) {}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package net.kewwbec.minigames.system;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
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.universe.Universe;
|
||||||
|
import com.hypixel.hytale.server.core.universe.world.World;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects player connect/disconnect events to queue and runtime state.
|
||||||
|
* Disconnecting removes the player from every queue and marks them LEFT in their
|
||||||
|
* runtime; reconnecting either re-activates them (AllowJoinMidgame) or restores
|
||||||
|
* their inventory and drops them from the game.
|
||||||
|
*/
|
||||||
|
public final class MinigameConnectionListener {
|
||||||
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
|
private final MinigameServices services;
|
||||||
|
|
||||||
|
public MinigameConnectionListener(MinigameServices services) {
|
||||||
|
this.services = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onPlayerDisconnect(@Nonnull PlayerDisconnectEvent event) {
|
||||||
|
String playerId = event.getPlayerRef().getUuid().toString();
|
||||||
|
services.queue().leaveAll(playerId);
|
||||||
|
services.runtime().runtimeForPlayer(playerId).ifPresent(runtime ->
|
||||||
|
runOnHomeWorld(runtime, () -> services.runtime().removePlayer(runtime, playerId, "disconnected")));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onPlayerConnect(@Nonnull PlayerConnectEvent event) {
|
||||||
|
String playerId = event.getPlayerRef().getUuid().toString();
|
||||||
|
services.runtime().runtimeForPlayer(playerId).ifPresent(runtime ->
|
||||||
|
runOnHomeWorld(runtime, () -> services.runtime().onPlayerReconnected(runtime, playerId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime mutation is confined to the runtime's home world thread. */
|
||||||
|
private static void runOnHomeWorld(MinigameRuntime runtime, Runnable action) {
|
||||||
|
Universe universe = Universe.get();
|
||||||
|
World world = universe != null && !runtime.worldName().isBlank()
|
||||||
|
? universe.getWorld(runtime.worldName())
|
||||||
|
: null;
|
||||||
|
if (world == null) {
|
||||||
|
LOGGER.atWarning().log("No home world for runtime=%s (world='%s'); running connection update inline",
|
||||||
|
runtime.runtimeId(), runtime.worldName());
|
||||||
|
action.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (world.isInThread()) {
|
||||||
|
action.run();
|
||||||
|
} else {
|
||||||
|
world.execute(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,12 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|||||||
import net.kewwbec.minigames.MinigameCorePlugin;
|
import net.kewwbec.minigames.MinigameCorePlugin;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
|
||||||
|
import static net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
|
||||||
public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem {
|
public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem {
|
||||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
private static final Set<Dependency<EntityStore>> DEPENDENCIES =
|
private static final Set<Dependency<EntityStore>> DEPENDENCIES =
|
||||||
@@ -63,10 +66,27 @@ public final class MinigameDeathRespawnSystem extends DeathSystems.OnDeathSystem
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eliminated or departed players are out of the game — let the normal death screen run.
|
||||||
|
if (player.status() == PlayerStatus.ELIMINATED || player.status() == PlayerStatus.LEFT
|
||||||
|
|| runtime.eliminatedPlayers().contains(playerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the game uses lives, dying costs one. Hitting zero eliminates instead of respawning.
|
||||||
|
if (runtime.definition().getDefaultPlayerLives() > 0) {
|
||||||
|
int remaining = Math.max(0, player.lives() - 1);
|
||||||
|
player.lives(remaining);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
player.status(PlayerStatus.ELIMINATED);
|
||||||
|
runtime.eliminatedPlayers().add(playerId);
|
||||||
|
services.runtime().dispatch(runtime, "on_player_eliminated",
|
||||||
|
Map.of("player_id", playerId, "cause", "lives_depleted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String destination = services.maps().respawnDestination(store, runtime, player, null);
|
String destination = services.maps().respawnDestination(store, runtime, player, null);
|
||||||
if (destination.isBlank()) {
|
if (destination.isBlank()) {
|
||||||
LOGGER.atInfo().log("Minigame respawn skipped player=%s runtime=%s reason=no checkpoint or team spawn",
|
|
||||||
playerId, runtime.runtimeId());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package net.kewwbec.minigames.system;
|
package net.kewwbec.minigames.system;
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.asset.TriggerEffectAsset;
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerEffect;
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
import com.hypixel.hytale.builtin.triggervolumes.manager.VolumeEntry;
|
||||||
import com.hypixel.hytale.component.Archetype;
|
import com.hypixel.hytale.component.Archetype;
|
||||||
@@ -22,6 +20,7 @@ import net.kewwbec.minigames.MinigameCorePlugin;
|
|||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
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.MinigameMapDiscoveryService;
|
||||||
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
import net.kewwbec.minigames.volume.effect.AwardKillScoreEffect;
|
import net.kewwbec.minigames.volume.effect.AwardKillScoreEffect;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
@@ -31,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
|
||||||
@@ -64,7 +64,7 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var services = MinigameCorePlugin.getServices();
|
MinigameServices services = MinigameCorePlugin.getServices();
|
||||||
if (services == null) {
|
if (services == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -73,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()) {
|
||||||
@@ -91,27 +94,16 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
|||||||
if (!matchesRuntime(volume, runtime) || !volume.getShape().contains(volume.getPosition(), transform.getPosition())) {
|
if (!matchesRuntime(volume, runtime) || !volume.getShape().contains(volume.getPosition(), transform.getPosition())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (var effect : effects(volume)) {
|
for (var effect : MinigameMapDiscoveryService.resolveEffects(volume)) {
|
||||||
if (effect instanceof AwardKillScoreEffect killScore && matchesRule(killScore, runtime, victimKind)) {
|
if (effect instanceof AwardKillScoreEffect killScore && matchesRule(killScore, runtime, victimKind)) {
|
||||||
apply(runtime, attackerId, killScore, friendlyKill);
|
apply(services, runtime, attackerId, killScore, friendlyKill);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
|
||||||
private static Iterable<TriggerEffect> effects(@Nonnull VolumeEntry volume) {
|
|
||||||
String assetRef = volume.getEffectAssetRef();
|
|
||||||
if (assetRef != null && !assetRef.isBlank()) {
|
|
||||||
TriggerEffectAsset asset = com.hypixel.hytale.assetstore.AssetRegistry.getAssetStore(TriggerEffectAsset.class).getAssetMap().getAsset(assetRef);
|
|
||||||
if (asset != null) {
|
|
||||||
return java.util.List.of(asset.getEffects());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return volume.getEffects();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void apply(
|
private static void apply(
|
||||||
|
@Nonnull MinigameServices services,
|
||||||
@Nonnull MinigameRuntime runtime,
|
@Nonnull MinigameRuntime runtime,
|
||||||
@Nonnull String attackerId,
|
@Nonnull String attackerId,
|
||||||
@Nonnull AwardKillScoreEffect rule,
|
@Nonnull AwardKillScoreEffect rule,
|
||||||
@@ -149,7 +141,7 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
|||||||
payload.put("player_id", attackerId);
|
payload.put("player_id", attackerId);
|
||||||
payload.put("points", points);
|
payload.put("points", points);
|
||||||
payload.put("friendly_kill", friendlyKill);
|
payload.put("friendly_kill", friendlyKill);
|
||||||
MinigameCorePlugin.getServices().runtime().dispatch(runtime, "on_kill_score", payload);
|
services.runtime().dispatch(runtime, "on_kill_score", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean matchesRule(@Nonnull AwardKillScoreEffect rule, @Nonnull MinigameRuntime runtime, @Nonnull String victimKind) {
|
private static boolean matchesRule(@Nonnull AwardKillScoreEffect rule, @Nonnull MinigameRuntime runtime, @Nonnull String victimKind) {
|
||||||
@@ -172,7 +164,8 @@ public final class MinigameKillScoreSystem extends DeathSystems.OnDeathSystem {
|
|||||||
String minigameId = clean(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID));
|
String minigameId = clean(tags.get(MinigameMapDiscoveryService.TAG_MINIGAME_ID));
|
||||||
String mapId = clean(tags.get(MinigameMapDiscoveryService.TAG_MAP_ID));
|
String mapId = clean(tags.get(MinigameMapDiscoveryService.TAG_MAP_ID));
|
||||||
String variantId = clean(tags.get(MinigameMapDiscoveryService.TAG_VARIANT_ID));
|
String variantId = clean(tags.get(MinigameMapDiscoveryService.TAG_VARIANT_ID));
|
||||||
if (!minigameId.isBlank() && !minigameId.equals(runtime.minigameId())) {
|
// Kill-score volumes must declare which minigame they belong to; untagged volumes are ignored.
|
||||||
|
if (minigameId.isBlank() || !minigameId.equals(runtime.minigameId())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!mapId.isBlank() && !mapId.equals(runtime.mapId())) {
|
if (!mapId.isBlank() && !mapId.equals(runtime.mapId())) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,24 +4,49 @@ import com.hypixel.hytale.component.Store;
|
|||||||
import com.hypixel.hytale.component.system.tick.TickingSystem;
|
import com.hypixel.hytale.component.system.tick.TickingSystem;
|
||||||
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 net.kewwbec.minigames.runtime.MinigameRuntimeService;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.service.MinigameStarter;
|
||||||
|
|
||||||
public final class MinigamePregameSystem extends TickingSystem<EntityStore> {
|
public final class MinigamePregameSystem extends TickingSystem<EntityStore> {
|
||||||
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
private static final HytaleLogger LOGGER = HytaleLogger.forEnclosingClass();
|
||||||
|
|
||||||
private final MinigameRuntimeService runtimeService;
|
private final MinigameServices services;
|
||||||
|
|
||||||
public MinigamePregameSystem(MinigameRuntimeService runtimeService) {
|
public MinigamePregameSystem(MinigameServices services) {
|
||||||
this.runtimeService = runtimeService;
|
this.services = services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
||||||
try {
|
try {
|
||||||
runtimeService.tickPregame(System.currentTimeMillis());
|
long now = System.currentTimeMillis();
|
||||||
|
String worldName = store.getExternalData().getWorld().getName();
|
||||||
|
services.runtime().tickPregame(now, worldName);
|
||||||
|
tickVoteWindows(now, store);
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
LOGGER.atWarning().log("Pregame tick failed: %s: %s",
|
LOGGER.atWarning().log("Pregame tick failed: %s: %s",
|
||||||
e.getClass().getSimpleName(), e.getMessage());
|
e.getClass().getSimpleName(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Starts queued games whose map-vote window has expired or completed. */
|
||||||
|
private void tickVoteWindows(long now, Store<EntityStore> store) {
|
||||||
|
for (String minigameId : services.queue().openVoteWindows()) {
|
||||||
|
if (!services.queue().voteWindowComplete(minigameId, now)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var defOpt = services.minigames().definition(minigameId);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!services.runtime().runtimesForMinigame(minigameId).isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only the world that actually hosts this game's volumes can start it.
|
||||||
|
if (services.maps().discover(store, minigameId).candidates().isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MinigameStarter.startQueued(services, defOpt.get(), store, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package net.kewwbec.minigames.system;
|
||||||
|
|
||||||
|
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.component.system.EntityEventSystem;
|
||||||
|
import com.hypixel.hytale.server.core.event.events.ecs.BreakBlockEvent;
|
||||||
|
import com.hypixel.hytale.server.core.event.events.ecs.PlaceBlockEvent;
|
||||||
|
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.runtime.MinigameRuntime;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the per-definition AllowPvp / AllowBlockBreaking / AllowBlockPlacing flags
|
||||||
|
* for players that are inside a minigame runtime.
|
||||||
|
*/
|
||||||
|
public final class MinigameRuleEnforcementSystems {
|
||||||
|
private MinigameRuleEnforcementSystems() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private static MinigameRuntime runtimeFor(@Nonnull Store<EntityStore> store, @Nonnull Ref<EntityStore> ref) {
|
||||||
|
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
|
if (player == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var services = MinigameCorePlugin.getServices();
|
||||||
|
if (services == null) {
|
||||||
|
return 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. */
|
||||||
|
public static final class PvpGuard extends DamageEventSystem {
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public SystemGroup<EntityStore> getGroup() {
|
||||||
|
return DamageModule.get().getInspectDamageGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Query<EntityStore> getQuery() {
|
||||||
|
return PlayerRef.getComponentType();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(
|
||||||
|
int index,
|
||||||
|
@Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
|
@Nonnull Damage damage
|
||||||
|
) {
|
||||||
|
if (damage.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(damage.getSource() instanceof Damage.EntitySource source)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Ref<EntityStore> attackerRef = source.getRef();
|
||||||
|
if (!attackerRef.isValid() || store.getComponent(attackerRef, PlayerRef.getComponentType()) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (victimRuntime != null && !victimRuntime.definition().isAllowPvp()) {
|
||||||
|
damage.setCancelled(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameRuntime attackerRuntime = runtimeFor(store, attackerRef);
|
||||||
|
if (attackerRuntime != null && !attackerRuntime.definition().isAllowPvp()) {
|
||||||
|
damage.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels block breaking by players whose minigame disables it. */
|
||||||
|
public static final class BlockBreakGuard extends EntityEventSystem<EntityStore, BreakBlockEvent> {
|
||||||
|
public BlockBreakGuard() {
|
||||||
|
super(BreakBlockEvent.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Query<EntityStore> getQuery() {
|
||||||
|
return PlayerRef.getComponentType();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(
|
||||||
|
int index,
|
||||||
|
@Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
|
@Nonnull BreakBlockEvent event
|
||||||
|
) {
|
||||||
|
if (event.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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()) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels block placing by players whose minigame disables it. */
|
||||||
|
public static final class BlockPlaceGuard extends EntityEventSystem<EntityStore, PlaceBlockEvent> {
|
||||||
|
public BlockPlaceGuard() {
|
||||||
|
super(PlaceBlockEvent.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
@Override
|
||||||
|
public Query<EntityStore> getQuery() {
|
||||||
|
return PlayerRef.getComponentType();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(
|
||||||
|
int index,
|
||||||
|
@Nonnull ArchetypeChunk<EntityStore> archetypeChunk,
|
||||||
|
@Nonnull Store<EntityStore> store,
|
||||||
|
@Nonnull CommandBuffer<EntityStore> commandBuffer,
|
||||||
|
@Nonnull PlaceBlockEvent event
|
||||||
|
) {
|
||||||
|
if (event.isCancelled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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()) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import net.kewwbec.minigames.adapter.HytaleAdapters;
|
|||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.model.PlayerMinigameState;
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.runtime.Rankings;
|
||||||
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
import net.kewwbec.minigames.volume.effect.StopPlayerTimerEffect;
|
||||||
|
|
||||||
import com.hypixel.hytale.component.Ref;
|
import com.hypixel.hytale.component.Ref;
|
||||||
@@ -22,7 +23,6 @@ import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
|
|||||||
import com.hypixel.hytale.logger.HytaleLogger;
|
import com.hypixel.hytale.logger.HytaleLogger;
|
||||||
|
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -56,16 +56,11 @@ public final class MinigameHudService {
|
|||||||
LOGGER.atWarning().log("[MinigameHUD] requestHudShow: playerId is not a UUID: '%s'", playerId);
|
LOGGER.atWarning().log("[MinigameHUD] requestHudShow: playerId is not a UUID: '%s'", playerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LOGGER.atInfo().log("[MinigameHUD] requestHudShow: queued uuid=%s minigame=%s", uuid, runtime.minigameId());
|
|
||||||
pendingOpens.put(uuid, new PendingHudOpen(uuid, playerId, runtime, System.currentTimeMillis()));
|
pendingOpens.put(uuid, new PendingHudOpen(uuid, playerId, runtime, System.currentTimeMillis()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void openQueued(Store<EntityStore> store) {
|
public void openQueued(Store<EntityStore> store) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
int pendingCount = pendingOpens.size();
|
|
||||||
if (pendingCount > 0) {
|
|
||||||
LOGGER.atInfo().log("[MinigameHUD] openQueued: processing %d pending HUD open(s)", pendingCount);
|
|
||||||
}
|
|
||||||
for (PendingHudOpen pending : pendingOpens.values()) {
|
for (PendingHudOpen pending : pendingOpens.values()) {
|
||||||
if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) {
|
if (now - pending.createdAtMillis() > OPEN_REQUEST_TTL_MILLIS) {
|
||||||
LOGGER.atWarning().log("[MinigameHUD] openQueued: TTL expired for uuid=%s, dropping",
|
LOGGER.atWarning().log("[MinigameHUD] openQueued: TTL expired for uuid=%s, dropping",
|
||||||
@@ -74,34 +69,31 @@ public final class MinigameHudService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.playerUuid());
|
Ref<EntityStore> ref = store.getExternalData().getRefFromUUID(pending.playerUuid());
|
||||||
if (ref == null) {
|
if (ref == null || !ref.isValid()) {
|
||||||
LOGGER.atInfo().log("[MinigameHUD] openQueued: ref not found yet for uuid=%s (will retry)",
|
|
||||||
pending.playerUuid());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!ref.isValid()) {
|
|
||||||
LOGGER.atWarning().log("[MinigameHUD] openQueued: ref invalid for uuid=%s", pending.playerUuid());
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
PlayerRef player = store.getComponent(ref, PlayerRef.getComponentType());
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
LOGGER.atWarning().log("[MinigameHUD] openQueued: PlayerRef component null for uuid=%s",
|
|
||||||
pending.playerUuid());
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (pendingOpens.remove(pending.playerUuid(), pending)) {
|
if (pendingOpens.remove(pending.playerUuid(), pending)) {
|
||||||
LOGGER.atInfo().log("[MinigameHUD] openQueued: opening HUD for uuid=%s", pending.playerUuid());
|
|
||||||
showHud(player, pending.playerId(), pending.runtime());
|
showHud(player, pending.playerId(), pending.runtime());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void refreshAll() {
|
public void refreshAll(Store<EntityStore> store) {
|
||||||
|
String worldName = store.getExternalData().getWorld().getName();
|
||||||
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
|
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
|
||||||
String playerId = entry.getKey();
|
String playerId = entry.getKey();
|
||||||
HudEntry hudEntry = entry.getValue();
|
HudEntry hudEntry = entry.getValue();
|
||||||
var runtime = hudEntry.runtime();
|
var runtime = hudEntry.runtime();
|
||||||
|
|
||||||
|
// Only the runtime's home world thread may touch its state.
|
||||||
|
if (!runtime.worldName().isBlank() && !runtime.worldName().equals(worldName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!runtime.players().containsKey(playerId)) {
|
if (!runtime.players().containsKey(playerId)) {
|
||||||
activeHuds.remove(playerId, hudEntry);
|
activeHuds.remove(playerId, hudEntry);
|
||||||
try {
|
try {
|
||||||
@@ -120,6 +112,20 @@ public final class MinigameHudService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void removeForPlayer(String playerId) {
|
||||||
|
HudEntry entry = activeHuds.remove(playerId);
|
||||||
|
if (entry != null) {
|
||||||
|
try {
|
||||||
|
entry.hud().remove();
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pendingOpens.remove(UUID.fromString(playerId));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void removeAllForRuntime(String runtimeId) {
|
public void removeAllForRuntime(String runtimeId) {
|
||||||
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
|
for (Map.Entry<String, HudEntry> entry : activeHuds.entrySet()) {
|
||||||
if (runtimeId.equals(entry.getValue().runtimeId())) {
|
if (runtimeId.equals(entry.getValue().runtimeId())) {
|
||||||
@@ -262,30 +268,8 @@ public final class MinigameHudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (content.sbNameLabels.length > 0) {
|
if (content.sbNameLabels.length > 0) {
|
||||||
WinCondition wc = runtime.definition().getWinCondition();
|
boolean lowestTime = runtime.definition().getWinCondition() == WinCondition.LOWEST_TIME;
|
||||||
boolean lowestTime = wc == WinCondition.LOWEST_TIME;
|
List<Map.Entry<String, PlayerMinigameState>> ranked = Rankings.rank(runtime, true);
|
||||||
List<Map.Entry<String, PlayerMinigameState>> ranked;
|
|
||||||
if (lowestTime) {
|
|
||||||
ranked = runtime.players().entrySet().stream()
|
|
||||||
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
|
|
||||||
.sorted((a, b) -> {
|
|
||||||
int sa = a.getValue().score();
|
|
||||||
int sb = b.getValue().score();
|
|
||||||
if (sa == 0 && sb == 0) return 0;
|
|
||||||
if (sa == 0) return 1;
|
|
||||||
if (sb == 0) return -1;
|
|
||||||
return Integer.compare(sa, sb);
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
|
|
||||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
|
|
||||||
ranked = runtime.players().entrySet().stream()
|
|
||||||
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
|
|
||||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
|
||||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < content.sbNameLabels.length; i++) {
|
for (int i = 0; i < content.sbNameLabels.length; i++) {
|
||||||
if (i < ranked.size()) {
|
if (i < ranked.size()) {
|
||||||
@@ -388,31 +372,7 @@ public final class MinigameHudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static String positionText(String playerId, MinigameRuntime runtime) {
|
private static String positionText(String playerId, MinigameRuntime runtime) {
|
||||||
WinCondition wc = runtime.definition().getWinCondition();
|
List<String> ranked = Rankings.rankedPlayerIds(runtime, true);
|
||||||
List<String> ranked;
|
|
||||||
if (wc == WinCondition.LOWEST_TIME) {
|
|
||||||
ranked = runtime.players().entrySet().stream()
|
|
||||||
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
|
|
||||||
.sorted((a, b) -> {
|
|
||||||
int sa = a.getValue().score();
|
|
||||||
int sb = b.getValue().score();
|
|
||||||
if (sa == 0 && sb == 0) return 0;
|
|
||||||
if (sa == 0) return 1;
|
|
||||||
if (sb == 0) return -1;
|
|
||||||
return Integer.compare(sa, sb);
|
|
||||||
})
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
boolean lowestWins = wc == WinCondition.LOWEST_SCORE;
|
|
||||||
var scoreOrder = Comparator.comparingInt((PlayerMinigameState p) -> p.score());
|
|
||||||
ranked = runtime.players().entrySet().stream()
|
|
||||||
.filter(e -> !runtime.eliminatedPlayers().contains(e.getKey()))
|
|
||||||
.sorted(Map.Entry.<String, PlayerMinigameState>comparingByValue(
|
|
||||||
lowestWins ? scoreOrder : scoreOrder.reversed()))
|
|
||||||
.map(Map.Entry::getKey)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
int activeCount = ranked.size();
|
int activeCount = ranked.size();
|
||||||
if (activeCount == 0)
|
if (activeCount == 0)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ public final class MinigameHudSystem extends TickingSystem<EntityStore> {
|
|||||||
private static final int REFRESH_EVERY_TICKS = 20;
|
private static final int REFRESH_EVERY_TICKS = 20;
|
||||||
|
|
||||||
private final MinigameHudService hudService;
|
private final MinigameHudService hudService;
|
||||||
private int tickCount = 0;
|
|
||||||
|
|
||||||
public MinigameHudSystem(MinigameHudService hudService) {
|
public MinigameHudSystem(MinigameHudService hudService) {
|
||||||
this.hudService = hudService;
|
this.hudService = hudService;
|
||||||
@@ -20,8 +19,9 @@ public final class MinigameHudSystem extends TickingSystem<EntityStore> {
|
|||||||
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
public void tick(float deltaSeconds, int tick, Store<EntityStore> store) {
|
||||||
try {
|
try {
|
||||||
hudService.openQueued(store);
|
hudService.openQueued(store);
|
||||||
if (tickCount++ % REFRESH_EVERY_TICKS == 0) {
|
// Use the world tick counter — a shared field would drift with world count.
|
||||||
hudService.refreshAll();
|
if (tick % REFRESH_EVERY_TICKS == 0) {
|
||||||
|
hudService.refreshAll(store);
|
||||||
}
|
}
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
LOGGER.atWarning().log("HUD system tick failed: %s: %s",
|
LOGGER.atWarning().log("HUD system tick failed: %s: %s",
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@ import au.ellie.hyui.builders.UIElementBuilder;
|
|||||||
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
import net.kewwbec.minigames.adapter.HytaleAdapters;
|
||||||
import net.kewwbec.minigames.model.MinigameDefinition;
|
import net.kewwbec.minigames.model.MinigameDefinition;
|
||||||
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
import net.kewwbec.minigames.service.MinigameQueueService;
|
||||||
import net.kewwbec.minigames.service.MinigameServices;
|
import net.kewwbec.minigames.service.MinigameServices;
|
||||||
|
import net.kewwbec.minigames.service.MinigameStarter;
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
import com.hypixel.hytale.builtin.triggervolumes.TriggerVolumesPlugin;
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
import com.hypixel.hytale.builtin.triggervolumes.manager.TriggerVolumeManager;
|
||||||
@@ -124,7 +126,7 @@ public final class MinigameQueueUIService {
|
|||||||
var def = defOpt.get();
|
var def = defOpt.get();
|
||||||
int queued = services.queue().players(gameId).size();
|
int queued = services.queue().players(gameId).size();
|
||||||
gameList.addChild(spacer(414, 6));
|
gameList.addChild(spacer(414, 6));
|
||||||
String label = def.getName() + " | " + queued + " queued";
|
String label = def.getName() + " | " + prettify(def.getGameType().name()) + " | " + queued + " queued";
|
||||||
String tooltip = def.getDescription().isBlank() ? def.getName() : def.getDescription();
|
String tooltip = def.getDescription().isBlank() ? def.getName() : def.getDescription();
|
||||||
gameList.addChild(inset(
|
gameList.addChild(inset(
|
||||||
ButtonBuilder.secondaryTextButton()
|
ButtonBuilder.secondaryTextButton()
|
||||||
@@ -361,6 +363,8 @@ public final class MinigameQueueUIService {
|
|||||||
.withTooltipText("Vote for " + prettify(map.mapId()))
|
.withTooltipText("Vote for " + prettify(map.mapId()))
|
||||||
.onClick((ignored, ui) -> {
|
.onClick((ignored, ui) -> {
|
||||||
services.queue().vote(minigameId, playerId, map.mapId());
|
services.queue().vote(minigameId, playerId, map.mapId());
|
||||||
|
services.minigames().definition(minigameId)
|
||||||
|
.ifPresent(d -> maybeStartAfterVote(d, store, System.currentTimeMillis()));
|
||||||
openVote(player, minigameId, store, manager);
|
openVote(player, minigameId, store, manager);
|
||||||
}),
|
}),
|
||||||
572, 510, 46));
|
572, 510, 46));
|
||||||
@@ -378,6 +382,8 @@ public final class MinigameQueueUIService {
|
|||||||
.withTooltipText("Vote for " + prettify(map.mapId()) + " — " + prettify(variant))
|
.withTooltipText("Vote for " + prettify(map.mapId()) + " — " + prettify(variant))
|
||||||
.onClick((ignored, ui) -> {
|
.onClick((ignored, ui) -> {
|
||||||
services.queue().vote(minigameId, playerId, voteKey);
|
services.queue().vote(minigameId, playerId, voteKey);
|
||||||
|
services.minigames().definition(minigameId)
|
||||||
|
.ifPresent(d -> maybeStartAfterVote(d, store, System.currentTimeMillis()));
|
||||||
openVote(player, minigameId, store, manager);
|
openVote(player, minigameId, store, manager);
|
||||||
}),
|
}),
|
||||||
572, 510, 38));
|
572, 510, 38));
|
||||||
@@ -434,26 +440,62 @@ public final class MinigameQueueUIService {
|
|||||||
|
|
||||||
private void joinQueue(PlayerRef player, String minigameId, String mapId, Store<EntityStore> store) {
|
private void joinQueue(PlayerRef player, String minigameId, String mapId, Store<EntityStore> store) {
|
||||||
String playerId = player.getUuid().toString();
|
String playerId = player.getUuid().toString();
|
||||||
services.queue().join(minigameId, playerId);
|
var defOpt = services.minigames().definition(minigameId);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var def = defOpt.get();
|
||||||
|
|
||||||
|
MinigameQueueService.JoinResult result = services.queue().join(def, playerId);
|
||||||
|
if (result == MinigameQueueService.JoinResult.QUEUE_FULL) {
|
||||||
|
sendMessage(playerId, Message.translation("minigames.queue.full").param("id", minigameId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result == MinigameQueueService.JoinResult.DISABLED) {
|
||||||
|
sendMessage(playerId, Message.translation("minigames.queue.disabled").param("id", minigameId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!mapId.isBlank()) {
|
if (!mapId.isBlank()) {
|
||||||
services.queue().vote(minigameId, playerId, mapId);
|
services.queue().vote(minigameId, playerId, mapId);
|
||||||
}
|
}
|
||||||
services.minigames().definition(minigameId).ifPresent(def -> {
|
|
||||||
Set<String> queued = services.queue().players(minigameId);
|
Set<String> queued = services.queue().players(minigameId);
|
||||||
if (services.runtime().runtimes(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) {
|
if (services.runtime().runtimesForMinigame(minigameId).isEmpty() && queued.size() >= def.getMinPlayers()) {
|
||||||
if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) {
|
long now = System.currentTimeMillis();
|
||||||
// Show vote UI — to all players when threshold is first hit, otherwise just the new joiner
|
if (def.getMapSelectionMode() == MapSelectionMode.VOTE && hasMultipleVoteChoices(store, minigameId)) {
|
||||||
Set<UUID> targets = queued.size() == def.getMinPlayers()
|
boolean newlyOpened = services.queue().openVoteWindow(minigameId, now);
|
||||||
? queued.stream().map(id -> { try { return UUID.fromString(id); } catch (IllegalArgumentException e) { return null; } }).filter(u -> u != null).collect(Collectors.toSet())
|
// Show the vote UI to everyone who has not voted yet — also when the
|
||||||
: Set.of(player.getUuid());
|
// threshold is re-reached after someone left and rejoined.
|
||||||
long now = System.currentTimeMillis();
|
Map<String, String> votes = services.queue().votes(minigameId);
|
||||||
targets.forEach(uuid -> pendingOpens.put(uuid, new PendingOpen(uuid, UIQueueMode.VOTE, minigameId, "", now)));
|
var targets = newlyOpened
|
||||||
} else {
|
? queued.stream().filter(id -> !votes.containsKey(id)).toList()
|
||||||
var discovered = services.maps().discover(store, minigameId);
|
: List.of(playerId);
|
||||||
services.queue().startQueued(def, discovered.candidates(), services.runtime());
|
for (String target : targets) {
|
||||||
|
try {
|
||||||
|
UUID uuid = UUID.fromString(target);
|
||||||
|
pendingOpens.put(uuid, new PendingOpen(uuid, UIQueueMode.VOTE, minigameId, "", now));
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
maybeStartAfterVote(def, store, now);
|
||||||
|
} else {
|
||||||
|
MinigameStarter.startQueued(services, def, store, false);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the game once every queued player has voted (the window timeout is handled by the pregame tick). */
|
||||||
|
private void maybeStartAfterVote(MinigameDefinition def, Store<EntityStore> store, long now) {
|
||||||
|
if (services.queue().voteWindowComplete(def.getId(), now)
|
||||||
|
&& services.runtime().runtimesForMinigame(def.getId()).isEmpty()) {
|
||||||
|
MinigameStarter.startQueued(services, def, store, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessage(String playerId, Message message) {
|
||||||
|
if (playerAdapter != null) {
|
||||||
|
playerAdapter.sendMessage(playerId, message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void leaveQueue(PlayerRef player, String minigameId) {
|
private void leaveQueue(PlayerRef player, String minigameId) {
|
||||||
|
|||||||
@@ -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() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,21 +41,27 @@ public abstract class MinigameRuntimeCondition extends TriggerCondition {
|
|||||||
if (services == null) {
|
if (services == null) {
|
||||||
return Optional.empty();
|
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);
|
String playerId = resolvePlayerId(context, null);
|
||||||
if (playerId != null) {
|
if (playerId != null) {
|
||||||
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
||||||
if (playerRuntime.isPresent()) {
|
// 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;
|
return playerRuntime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
if (declaredId.isBlank()) {
|
||||||
String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId;
|
|
||||||
if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) {
|
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId())
|
return services.runtime().runtimeForArena(declaredId, candidate.mapId(), candidate.variantId())
|
||||||
.or(() -> services.runtime().runtime(resolvedMinigameId));
|
.or(() -> services.runtime().runtimesForMinigame(declaredId).stream().findFirst());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public final class WinConditionMetCondition extends MinigameRuntimeCondition {
|
|||||||
.count();
|
.count();
|
||||||
yield alive <= 1;
|
yield alive <= 1;
|
||||||
}
|
}
|
||||||
case FIRST_TO_SCORE -> rt.get().players().values().stream()
|
case FIRST_TO_SCORE -> targetScore > 0 && rt.get().players().values().stream()
|
||||||
.anyMatch(p -> p.score() >= targetScore);
|
.anyMatch(p -> p.score() >= targetScore);
|
||||||
case OBJECTIVE_COMPLETE -> !rt.get().objectives().isEmpty()
|
case OBJECTIVE_COMPLETE -> !rt.get().objectives().isEmpty()
|
||||||
&& rt.get().objectives().values().stream()
|
&& rt.get().objectives().values().stream()
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
|||||||
rt.get().teams().computeIfAbsent(teamId, k -> new TeamState(k, k, null)).players().add(id);
|
rt.get().teams().computeIfAbsent(teamId, k -> new TeamState(k, k, null)).players().add(id);
|
||||||
player.teamId(teamId);
|
player.teamId(teamId);
|
||||||
|
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' team=" + prevTeam + "->" + teamId);
|
debugMessage(context, TYPE_ID, "FIRED " + displayName(id) + " team " + (prevTeam == null || prevTeam.isBlank() ? "none" : prevTeam) + " -> " + teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void balanceTeams(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
private void balanceTeams(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
@@ -88,14 +88,17 @@ public final class AssignTeamEffect extends MinigameRuntimeEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> players = runtime.players().keySet().stream().sorted().toList();
|
List<String> players = runtime.players().keySet().stream().sorted().toList();
|
||||||
|
List<String> assignments = new ArrayList<>(players.size());
|
||||||
for (int i = 0; i < players.size(); i++) {
|
for (int i = 0; i < players.size(); i++) {
|
||||||
String playerId = players.get(i);
|
String playerId = players.get(i);
|
||||||
TeamState team = teams.get(i % teams.size());
|
TeamState team = teams.get(i % teams.size());
|
||||||
runtime.players().get(playerId).teamId(team.teamId());
|
runtime.players().get(playerId).teamId(team.teamId());
|
||||||
team.players().add(playerId);
|
team.players().add(playerId);
|
||||||
|
assignments.add(displayName(playerId) + " -> " + team.teamId());
|
||||||
}
|
}
|
||||||
|
|
||||||
debugMessage(context, TYPE_ID, "FIRED balanced players=" + players.size() + " teams=" + teams.size());
|
debugMessage(context, TYPE_ID, "FIRED balanced " + players.size() + " players across "
|
||||||
|
+ teams.size() + " teams: " + String.join(", ", assignments));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ public final class DismountPlayerEffect extends MinigameRuntimeEffect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.getStore().tryRemoveComponent(playerRef, LockMountComponent.getComponentType());
|
var lockType = LockMountComponent.getComponentType();
|
||||||
|
if (lockType != null) {
|
||||||
|
context.getStore().tryRemoveComponent(playerRef, lockType);
|
||||||
|
}
|
||||||
MountPlugin.checkDismountNpc(context.getStore(), playerRef, playerComponent);
|
MountPlugin.checkDismountNpc(context.getStore(), playerRef, playerComponent);
|
||||||
|
|
||||||
if (removeNpc && npcRef != null && npcRef.isValid()) {
|
if (removeNpc && npcRef != null && npcRef.isValid()) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public final class EndMinigameEffect extends MinigameRuntimeEffect {
|
|||||||
var rt = runtime(context);
|
var rt = runtime(context);
|
||||||
if (rt.isPresent()) {
|
if (rt.isPresent()) {
|
||||||
String stopReason = reason != null ? reason : "minigames.runtime.reason.trigger_volume";
|
String stopReason = reason != null ? reason : "minigames.runtime.reason.trigger_volume";
|
||||||
services.runtime().end(rt.get().runtimeId(), stopReason);
|
services.runtime().endRuntime(rt.get().runtimeId(), stopReason);
|
||||||
debugMessage(context, TYPE_ID, "FIRED reason='" + stopReason + "'");
|
debugMessage(context, TYPE_ID, "FIRED reason='" + stopReason + "'");
|
||||||
} else {
|
} else {
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package net.kewwbec.minigames.volume.effect;
|
package net.kewwbec.minigames.volume.effect;
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
import com.hypixel.hytale.codec.Codec;
|
|
||||||
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.service.MinigameQueueService;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -20,11 +20,27 @@ public final class JoinMinigameQueueEffect extends MinigameRuntimeEffect {
|
|||||||
var services = services();
|
var services = services();
|
||||||
String playerId = resolvePlayerId(context, null);
|
String playerId = resolvePlayerId(context, null);
|
||||||
String id = resolvedMinigameId(context);
|
String id = resolvedMinigameId(context);
|
||||||
if (services != null && playerId != null && !id.isBlank()) {
|
if (services == null || playerId == null || id.isBlank()) {
|
||||||
services.queue().join(id, playerId);
|
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' joined queue for minigame='" + id + "'");
|
|
||||||
} else {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')");
|
debugMessage(context, TYPE_ID, "SKIPPED (services=" + services + " player='" + playerId + "' id='" + id + "')");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var defOpt = services.minigames().definition(id);
|
||||||
|
if (defOpt.isEmpty()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (definition not found for id='" + id + "')");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MinigameQueueService.JoinResult result = services.queue().join(defOpt.get(), playerId);
|
||||||
|
switch (result) {
|
||||||
|
case JOINED -> debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' joined queue for minigame='" + id + "'");
|
||||||
|
case ALREADY_QUEUED -> debugMessage(context, TYPE_ID, "SKIPPED (player='" + playerId + "' already queued)");
|
||||||
|
case QUEUE_FULL -> {
|
||||||
|
services.adapters().sendMessage(playerId, Message.translation("minigames.queue.full").param("id", id));
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (queue full for minigame='" + id + "')");
|
||||||
|
}
|
||||||
|
case DISABLED -> {
|
||||||
|
services.adapters().sendMessage(playerId, Message.translation("minigames.queue.disabled").param("id", id));
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (minigame='" + id + "' is disabled)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package net.kewwbec.minigames.volume.effect;
|
|||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
|
import net.kewwbec.minigames.model.Enums.MinigamePhase;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
|
||||||
@@ -28,12 +29,12 @@ public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect {
|
|||||||
|
|
||||||
// Also remove from the runtime if the game is still in the pregame window.
|
// Also remove from the runtime if the game is still in the pregame window.
|
||||||
// This triggers the min-player check on the next pregame tick.
|
// This triggers the min-player check on the next pregame tick.
|
||||||
services.runtime().runtimes(id).forEach(runtime -> {
|
services.runtime().runtimesForMinigame(id).forEach(runtime -> {
|
||||||
var phase = runtime.phase();
|
var phase = runtime.phase();
|
||||||
if (phase == net.kewwbec.minigames.model.Enums.MinigamePhase.WAITING_FOR_PLAYERS
|
if (phase == MinigamePhase.WAITING_FOR_PLAYERS
|
||||||
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.COUNTDOWN
|
|| phase == MinigamePhase.COUNTDOWN
|
||||||
|| phase == net.kewwbec.minigames.model.Enums.MinigamePhase.STARTING) {
|
|| phase == MinigamePhase.STARTING) {
|
||||||
runtime.players().remove(playerId);
|
services.runtime().removePlayer(runtime, playerId, "left_queue");
|
||||||
debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'");
|
debugMessage(context, TYPE_ID, "removed player='" + playerId + "' from pregame runtime minigame='" + id + "'");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -41,4 +42,3 @@ public final class LeaveMinigameQueueEffect extends MinigameRuntimeEffect {
|
|||||||
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'");
|
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' left queue for minigame='" + id + "'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,84 +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();
|
|
||||||
}
|
|
||||||
String playerId = resolvePlayerId(context, null);
|
|
||||||
if (playerId != null) {
|
|
||||||
Optional<MinigameRuntime> playerRuntime = services.runtime().runtimeForPlayer(playerId);
|
|
||||||
if (playerRuntime.isPresent()) {
|
|
||||||
return playerRuntime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var candidate = services.maps().candidateFromVolume(context, minigameId != null ? minigameId : "");
|
@Nonnull
|
||||||
String resolvedMinigameId = !candidate.minigameId().isBlank() ? candidate.minigameId() : minigameId;
|
protected Optional<MinigameRuntime> runtime(@Nonnull MinigameActionContext context) {
|
||||||
if (resolvedMinigameId == null || resolvedMinigameId.isBlank()) {
|
return MinigameActionSupport.runtime(context, minigameId);
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
return services.runtime().runtimeForArena(resolvedMinigameId, candidate.mapId(), candidate.variantId())
|
|
||||||
.or(() -> services.runtime().runtime(resolvedMinigameId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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());
|
/** Player username for debug output, falling back to the raw id when offline/unresolved. */
|
||||||
if (playerRef != null) {
|
@Nonnull
|
||||||
playerRef.sendMessage(
|
protected static String displayName(@Nonnull String playerId) {
|
||||||
Message.translation("minigames.debug.effect")
|
return MinigameActionSupport.displayName(playerId);
|
||||||
.param("type", effectType)
|
|
||||||
.param("details", details)
|
|
||||||
.color(Color.CYAN)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ public final class ModifyCounterEffect extends MinigameRuntimeEffect {
|
|||||||
BuilderCodec.builder(ModifyCounterEffect.class, ModifyCounterEffect::new, MINIGAME_BASE_CODEC)
|
BuilderCodec.builder(ModifyCounterEffect.class, ModifyCounterEffect::new, MINIGAME_BASE_CODEC)
|
||||||
.append(new KeyedCodec<>("CounterId", Codec.STRING), (effect, value) -> effect.counterId = value, effect -> effect.counterId)
|
.append(new KeyedCodec<>("CounterId", Codec.STRING), (effect, value) -> effect.counterId = value, effect -> effect.counterId)
|
||||||
.add()
|
.add()
|
||||||
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyPlayerScoreEffect.Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
||||||
.add()
|
.add()
|
||||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
.add()
|
.add()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private String counterId;
|
private String counterId;
|
||||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD;
|
||||||
private int amount = 1;
|
private int amount = 1;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -37,7 +37,7 @@ public final class ModifyCounterEffect extends MinigameRuntimeEffect {
|
|||||||
if (rt.isPresent()) {
|
if (rt.isPresent()) {
|
||||||
String key = "counter:" + counterId;
|
String key = "counter:" + counterId;
|
||||||
int current = number(rt.get().variables().get(key));
|
int current = number(rt.get().variables().get(key));
|
||||||
int next = ModifyPlayerScoreEffect.apply(current, operation, amount);
|
int next = ModifyScoreEffect.apply(current, operation, amount);
|
||||||
rt.get().variables().put(key, next);
|
rt.get().variables().put(key, next);
|
||||||
debugMessage(context, TYPE_ID, "FIRED counter='" + counterId + "' " + current + "->" + next);
|
debugMessage(context, TYPE_ID, "FIRED counter='" + counterId + "' " + current + "->" + next);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package net.kewwbec.minigames.volume.effect;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
|
import com.hypixel.hytale.codec.Codec;
|
||||||
|
import com.hypixel.hytale.codec.KeyedCodec;
|
||||||
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
|
import com.hypixel.hytale.codec.codecs.EnumCodec;
|
||||||
|
import net.kewwbec.minigames.model.Enums.PlayerStatus;
|
||||||
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public final class ModifyLivesEffect extends MinigameRuntimeEffect {
|
||||||
|
public static final String TYPE_ID = "ModifyLives";
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static final BuilderCodec<ModifyLivesEffect> CODEC =
|
||||||
|
BuilderCodec.builder(ModifyLivesEffect.class, ModifyLivesEffect::new, MINIGAME_BASE_CODEC)
|
||||||
|
.append(new KeyedCodec<>("TargetType", new EnumCodec<>(ModifyScoreEffect.TargetType.class), false),
|
||||||
|
(effect, value) -> effect.targetType = value, effect -> effect.targetType)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("TargetSource", new EnumCodec<>(ModifyScoreEffect.TargetSource.class), false),
|
||||||
|
(effect, value) -> effect.targetSource = value, effect -> effect.targetSource)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false),
|
||||||
|
(effect, value) -> effect.operation = value, effect -> effect.operation)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false),
|
||||||
|
(effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
|
.add()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private ModifyScoreEffect.TargetType targetType = ModifyScoreEffect.TargetType.PLAYER;
|
||||||
|
private ModifyScoreEffect.TargetSource targetSource = ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER;
|
||||||
|
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD;
|
||||||
|
private int amount = 1;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(@Nonnull TriggerContext context) {
|
||||||
|
var rt = runtime(context);
|
||||||
|
if (rt.isEmpty()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> targets = resolvePlayerTargets(context, rt.get());
|
||||||
|
if (targets.isEmpty()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (no player target resolved)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int changed = 0;
|
||||||
|
for (String playerId : targets) {
|
||||||
|
PlayerMinigameState player = rt.get().players().get(playerId);
|
||||||
|
if (player == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int prev = player.lives();
|
||||||
|
int next = Math.max(0, ModifyScoreEffect.apply(prev, operation, amount));
|
||||||
|
player.lives(next);
|
||||||
|
changed++;
|
||||||
|
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' lives " + prev + "->" + next);
|
||||||
|
|
||||||
|
if (prev > 0 && next <= 0 && player.status() == PlayerStatus.ACTIVE) {
|
||||||
|
player.status(PlayerStatus.ELIMINATED);
|
||||||
|
rt.get().eliminatedPlayers().add(playerId);
|
||||||
|
var svc = services();
|
||||||
|
if (svc != null) {
|
||||||
|
svc.runtime().dispatch(rt.get(), "on_player_eliminated",
|
||||||
|
Map.of("player_id", playerId, "cause", "lives_depleted"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed == 0) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (resolved player targets not in runtime)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private List<String> resolvePlayerTargets(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
|
ModifyScoreEffect.TargetType type = targetType != null ? targetType : ModifyScoreEffect.TargetType.PLAYER;
|
||||||
|
ModifyScoreEffect.TargetSource source = targetSource != null ? targetSource : ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER;
|
||||||
|
|
||||||
|
if (type == ModifyScoreEffect.TargetType.PLAYER && source == ModifyScoreEffect.TargetSource.TRIGGERING_PLAYER) {
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
return playerId != null ? List.of(playerId) : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String teamId = resolveTeamId(context, runtime, source);
|
||||||
|
if (teamId == null || teamId.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = runtime.teams().get(teamId);
|
||||||
|
if (team != null) {
|
||||||
|
return new ArrayList<>(team.players());
|
||||||
|
}
|
||||||
|
|
||||||
|
return runtime.players().entrySet().stream()
|
||||||
|
.filter(entry -> teamId.equals(entry.getValue().teamId()))
|
||||||
|
.map(java.util.Map.Entry::getKey)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String resolveTeamId(
|
||||||
|
@Nonnull TriggerContext context,
|
||||||
|
@Nonnull MinigameRuntime runtime,
|
||||||
|
@Nonnull ModifyScoreEffect.TargetSource source
|
||||||
|
) {
|
||||||
|
if (source == ModifyScoreEffect.TargetSource.TAG_TEAM_ID) {
|
||||||
|
return clean(context.getVolume() != null
|
||||||
|
? context.getVolume().getRawTags().get(MinigameMapDiscoveryService.TAG_TEAM_ID)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
PlayerMinigameState player = playerId != null ? runtime.players().get(playerId) : null;
|
||||||
|
return player != null ? clean(player.teamId()) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private static String clean(@Nullable String value) {
|
||||||
|
return value != null && !value.isBlank() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -19,14 +19,14 @@ public final class ModifyObjectiveProgressEffect extends MinigameRuntimeEffect {
|
|||||||
BuilderCodec.builder(ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect::new, MINIGAME_BASE_CODEC)
|
BuilderCodec.builder(ModifyObjectiveProgressEffect.class, ModifyObjectiveProgressEffect::new, MINIGAME_BASE_CODEC)
|
||||||
.append(new KeyedCodec<>("ObjectiveId", Codec.STRING), (effect, value) -> effect.objectiveId = value, effect -> effect.objectiveId)
|
.append(new KeyedCodec<>("ObjectiveId", Codec.STRING), (effect, value) -> effect.objectiveId = value, effect -> effect.objectiveId)
|
||||||
.add()
|
.add()
|
||||||
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyPlayerScoreEffect.Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyScoreEffect.Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
||||||
.add()
|
.add()
|
||||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
.append(new KeyedCodec<>("Amount", Codec.INTEGER), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
.add()
|
.add()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
private String objectiveId;
|
private String objectiveId;
|
||||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
private ModifyScoreEffect.Operation operation = ModifyScoreEffect.Operation.ADD;
|
||||||
private int amount = 1;
|
private int amount = 1;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -45,7 +45,7 @@ public final class ModifyObjectiveProgressEffect extends MinigameRuntimeEffect {
|
|||||||
k -> new ObjectiveState(k, k, "generic"));
|
k -> new ObjectiveState(k, k, "generic"));
|
||||||
|
|
||||||
int prev = objective.progress();
|
int prev = objective.progress();
|
||||||
int next = Math.max(0, ModifyPlayerScoreEffect.apply(prev, operation, amount));
|
int next = Math.max(0, ModifyScoreEffect.apply(prev, operation, amount));
|
||||||
objective.progress(next);
|
objective.progress(next);
|
||||||
|
|
||||||
if (next >= objective.requiredProgress() && objective.status() == ObjectiveStatus.ACTIVE) {
|
if (next >= objective.requiredProgress() && objective.status() == ObjectiveStatus.ACTIVE) {
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package net.kewwbec.minigames.volume.effect;
|
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
|
||||||
import com.hypixel.hytale.codec.Codec;
|
|
||||||
import com.hypixel.hytale.codec.KeyedCodec;
|
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
|
||||||
import com.hypixel.hytale.codec.codecs.EnumCodec;
|
|
||||||
import net.kewwbec.minigames.model.Enums.PlayerStatus;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public final class ModifyPlayerLivesEffect extends MinigameRuntimeEffect {
|
|
||||||
public static final String TYPE_ID = "ModifyPlayerLives";
|
|
||||||
|
|
||||||
@Nonnull
|
|
||||||
public static final BuilderCodec<ModifyPlayerLivesEffect> CODEC =
|
|
||||||
BuilderCodec.builder(ModifyPlayerLivesEffect.class, ModifyPlayerLivesEffect::new, MINIGAME_BASE_CODEC)
|
|
||||||
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
|
||||||
.add()
|
|
||||||
.append(new KeyedCodec<>("Operation", new EnumCodec<>(ModifyPlayerScoreEffect.Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
|
||||||
.add()
|
|
||||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
|
||||||
.add()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
private String playerId;
|
|
||||||
private ModifyPlayerScoreEffect.Operation operation = ModifyPlayerScoreEffect.Operation.ADD;
|
|
||||||
private int amount = 1;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void execute(@Nonnull TriggerContext context) {
|
|
||||||
String id = resolvePlayerId(context, playerId);
|
|
||||||
if (id == null) {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rt = runtime(context);
|
|
||||||
if (rt.isPresent()) {
|
|
||||||
var player = rt.get().players().get(id);
|
|
||||||
if (player != null) {
|
|
||||||
int prev = player.lives();
|
|
||||||
int next = Math.max(0, ModifyPlayerScoreEffect.apply(prev, operation, amount));
|
|
||||||
player.lives(next);
|
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' lives " + prev + "->" + next);
|
|
||||||
|
|
||||||
if (prev > 0 && next <= 0 && player.status() == PlayerStatus.ACTIVE) {
|
|
||||||
player.status(PlayerStatus.ELIMINATED);
|
|
||||||
rt.get().eliminatedPlayers().add(id);
|
|
||||||
var svc = services();
|
|
||||||
if (svc != null) {
|
|
||||||
svc.runtime().dispatch(rt.get(), "on_player_eliminated",
|
|
||||||
Map.of("player_id", id, "cause", "lives_depleted"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package net.kewwbec.minigames.volume.effect;
|
|
||||||
|
|
||||||
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
|
||||||
import com.hypixel.hytale.codec.Codec;
|
|
||||||
import com.hypixel.hytale.codec.KeyedCodec;
|
|
||||||
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
|
||||||
import com.hypixel.hytale.codec.codecs.EnumCodec;
|
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
|
||||||
|
|
||||||
public final class ModifyPlayerScoreEffect extends MinigameRuntimeEffect {
|
|
||||||
public static final String TYPE_ID = "ModifyPlayerScore";
|
|
||||||
|
|
||||||
@Nonnull
|
|
||||||
public static final BuilderCodec<ModifyPlayerScoreEffect> CODEC =
|
|
||||||
BuilderCodec.builder(ModifyPlayerScoreEffect.class, ModifyPlayerScoreEffect::new, MINIGAME_BASE_CODEC)
|
|
||||||
.append(new KeyedCodec<>("PlayerId", Codec.STRING, false), (effect, value) -> effect.playerId = value, effect -> effect.playerId)
|
|
||||||
.add()
|
|
||||||
.append(new KeyedCodec<>("Operation", new EnumCodec<>(Operation.class), false), (effect, value) -> effect.operation = value, effect -> effect.operation)
|
|
||||||
.add()
|
|
||||||
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false), (effect, value) -> effect.amount = value, effect -> effect.amount)
|
|
||||||
.add()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
private String playerId;
|
|
||||||
private Operation operation = Operation.ADD;
|
|
||||||
private int amount = 1;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void execute(@Nonnull TriggerContext context) {
|
|
||||||
String id = resolvePlayerId(context, playerId);
|
|
||||||
if (id == null) {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (no player resolved)");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rt = runtime(context);
|
|
||||||
if (rt.isPresent()) {
|
|
||||||
var player = rt.get().players().get(id);
|
|
||||||
if (player != null) {
|
|
||||||
int prev = player.score();
|
|
||||||
int next = apply(prev, operation, amount);
|
|
||||||
player.score(next);
|
|
||||||
debugMessage(context, TYPE_ID, "FIRED player='" + id + "' score " + prev + "->" + next);
|
|
||||||
} else {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (player='" + id + "' not in runtime)");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static int apply(int current, Operation operation, int amount) {
|
|
||||||
return switch (operation != null ? operation : Operation.ADD) {
|
|
||||||
case SET -> amount;
|
|
||||||
case ADD -> current + amount;
|
|
||||||
case SUBTRACT -> current - amount;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum Operation {
|
|
||||||
SET,
|
|
||||||
ADD,
|
|
||||||
SUBTRACT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package net.kewwbec.minigames.volume.effect;
|
||||||
|
|
||||||
|
import com.hypixel.hytale.builtin.triggervolumes.effect.TriggerContext;
|
||||||
|
import com.hypixel.hytale.codec.Codec;
|
||||||
|
import com.hypixel.hytale.codec.KeyedCodec;
|
||||||
|
import com.hypixel.hytale.codec.builder.BuilderCodec;
|
||||||
|
import com.hypixel.hytale.codec.codecs.EnumCodec;
|
||||||
|
import net.kewwbec.minigames.model.PlayerMinigameState;
|
||||||
|
import net.kewwbec.minigames.runtime.MinigameRuntime;
|
||||||
|
import net.kewwbec.minigames.service.MinigameMapDiscoveryService;
|
||||||
|
|
||||||
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ModifyScoreEffect extends MinigameRuntimeEffect {
|
||||||
|
public static final String TYPE_ID = "ModifyScore";
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
public static final BuilderCodec<ModifyScoreEffect> CODEC =
|
||||||
|
BuilderCodec.builder(ModifyScoreEffect.class, ModifyScoreEffect::new, MINIGAME_BASE_CODEC)
|
||||||
|
.append(new KeyedCodec<>("TargetType", new EnumCodec<>(TargetType.class), false),
|
||||||
|
(effect, value) -> effect.targetType = value, effect -> effect.targetType)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("TargetSource", new EnumCodec<>(TargetSource.class), false),
|
||||||
|
(effect, value) -> effect.targetSource = value, effect -> effect.targetSource)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("Operation", new EnumCodec<>(Operation.class), false),
|
||||||
|
(effect, value) -> effect.operation = value, effect -> effect.operation)
|
||||||
|
.add()
|
||||||
|
.append(new KeyedCodec<>("Amount", Codec.INTEGER, false),
|
||||||
|
(effect, value) -> effect.amount = value, effect -> effect.amount)
|
||||||
|
.add()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private TargetType targetType = TargetType.PLAYER;
|
||||||
|
private TargetSource targetSource = TargetSource.TRIGGERING_PLAYER;
|
||||||
|
private Operation operation = Operation.ADD;
|
||||||
|
private int amount = 1;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(@Nonnull TriggerContext context) {
|
||||||
|
var rt = runtime(context);
|
||||||
|
if (rt.isEmpty()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (no runtime for minigame='" + resolvedMinigameId(context) + "')");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TargetType type = targetType != null ? targetType : TargetType.PLAYER;
|
||||||
|
if (type == TargetType.TEAM) {
|
||||||
|
modifyTeamScore(context, rt.get());
|
||||||
|
} else {
|
||||||
|
modifyPlayerScores(context, rt.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void modifyPlayerScores(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
|
List<String> playerIds = resolvePlayerTargets(context, runtime);
|
||||||
|
if (playerIds.isEmpty()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (no player target resolved)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int changed = 0;
|
||||||
|
for (String playerId : playerIds) {
|
||||||
|
PlayerMinigameState player = runtime.players().get(playerId);
|
||||||
|
if (player == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int prev = player.score();
|
||||||
|
int next = apply(prev, operation, amount);
|
||||||
|
player.score(next);
|
||||||
|
changed++;
|
||||||
|
debugMessage(context, TYPE_ID, "FIRED player='" + playerId + "' score " + prev + "->" + next);
|
||||||
|
}
|
||||||
|
if (changed == 0) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (resolved player targets not in runtime)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void modifyTeamScore(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
|
String teamId = resolveTeamId(context, runtime);
|
||||||
|
if (teamId == null || teamId.isBlank()) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (no team target resolved)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = runtime.teams().get(teamId);
|
||||||
|
if (team == null) {
|
||||||
|
debugMessage(context, TYPE_ID, "SKIPPED (team='" + teamId + "' not in runtime)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int prev = team.score();
|
||||||
|
int next = apply(prev, operation, amount);
|
||||||
|
team.score(next);
|
||||||
|
debugMessage(context, TYPE_ID, "FIRED team='" + teamId + "' score " + prev + "->" + next);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private List<String> resolvePlayerTargets(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
|
TargetSource source = targetSource != null ? targetSource : TargetSource.TRIGGERING_PLAYER;
|
||||||
|
if (source == TargetSource.TRIGGERING_PLAYER) {
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
return playerId != null ? List.of(playerId) : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String teamId = resolveTeamId(context, runtime);
|
||||||
|
if (teamId == null || teamId.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
var team = runtime.teams().get(teamId);
|
||||||
|
if (team != null) {
|
||||||
|
return new ArrayList<>(team.players());
|
||||||
|
}
|
||||||
|
|
||||||
|
return runtime.players().entrySet().stream()
|
||||||
|
.filter(entry -> teamId.equals(entry.getValue().teamId()))
|
||||||
|
.map(java.util.Map.Entry::getKey)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private String resolveTeamId(@Nonnull TriggerContext context, @Nonnull MinigameRuntime runtime) {
|
||||||
|
TargetSource source = targetSource != null ? targetSource : TargetSource.TRIGGERING_PLAYER;
|
||||||
|
if (source == TargetSource.TAG_TEAM_ID) {
|
||||||
|
return clean(context.getVolume() != null
|
||||||
|
? context.getVolume().getRawTags().get(MinigameMapDiscoveryService.TAG_TEAM_ID)
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
|
String playerId = resolvePlayerId(context, null);
|
||||||
|
PlayerMinigameState player = playerId != null ? runtime.players().get(playerId) : null;
|
||||||
|
return player != null ? clean(player.teamId()) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int apply(int current, Operation operation, int amount) {
|
||||||
|
return switch (operation != null ? operation : Operation.ADD) {
|
||||||
|
case SET -> amount;
|
||||||
|
case ADD -> current + amount;
|
||||||
|
case SUBTRACT -> current - amount;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nonnull
|
||||||
|
private static String clean(@Nullable String value) {
|
||||||
|
return value != null && !value.isBlank() ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TargetType {
|
||||||
|
PLAYER,
|
||||||
|
TEAM
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TargetSource {
|
||||||
|
TRIGGERING_PLAYER,
|
||||||
|
TRIGGERING_TEAM,
|
||||||
|
TAG_TEAM_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Operation {
|
||||||
|
SET,
|
||||||
|
ADD,
|
||||||
|
SUBTRACT
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user